From b2dc6d8a3f4afbb6e51004b2d8238775aca8d749 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:11:50 +0200 Subject: [PATCH 01/19] feat: add etf backtest cli --- .gitignore | 4 +- AGENTS.md | 27 +- README.md | 21 +- agent/PLANS.md | 136 +++++ package.json | 3 +- scripts/scaffold-cli.ts | 25 +- src/cli/etf-backtest/README.md | 156 +++++ src/cli/etf-backtest/constants.ts | 84 +++ src/cli/etf-backtest/main.ts | 532 ++++++++++++++++++ src/cli/etf-backtest/schemas.ts | 68 +++ src/cli/etf-backtest/scripts/backtest.py | 171 ++++++ src/cli/etf-backtest/scripts/predict.py | 206 +++++++ .../etf-backtest/scripts/run_experiment.py | 379 +++++++++++++ src/cli/etf-backtest/scripts/shared.py | 288 ++++++++++ src/tools/run-python/run-python-tool.test.ts | 245 ++++++++ src/tools/run-python/run-python-tool.ts | 265 +++++++++ 16 files changed, 2591 insertions(+), 19 deletions(-) create mode 100644 agent/PLANS.md create mode 100644 src/cli/etf-backtest/README.md create mode 100644 src/cli/etf-backtest/constants.ts create mode 100644 src/cli/etf-backtest/main.ts create mode 100644 src/cli/etf-backtest/schemas.ts create mode 100644 src/cli/etf-backtest/scripts/backtest.py create mode 100644 src/cli/etf-backtest/scripts/predict.py create mode 100644 src/cli/etf-backtest/scripts/run_experiment.py create mode 100644 src/cli/etf-backtest/scripts/shared.py create mode 100644 src/tools/run-python/run-python-tool.test.ts create mode 100644 src/tools/run-python/run-python-tool.ts diff --git a/.gitignore b/.gitignore index e9abee3..bf59b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules .env -tmp \ No newline at end of file +tmp +.venv +__pycache__ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c4debf4..9f38013 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,9 +9,10 @@ 1. Start at `src/cli//main.ts` and the matching `src/cli//README.md`. 2. Follow the pipeline classes under `src/cli//clients/*` and schemas under `src/cli//types/*`. 3. Reuse shared helpers: `src/utils/parse-args.ts`, `src/utils/question-handler.ts`, `src/clients/logger.ts`. -4. Keep changes minimal; add/update **Vitest** tests (`*.test.ts`) when behavior changes. -5. Run: `pnpm typecheck`, `pnpm lint`, `pnpm test` (and `pnpm format:check` if formatting changed). -6. All runtime artifacts go under `tmp/` (never commit them). +4. Keep `main.ts` focused on the basic agent flow; move non-trivial logic into `clients/` or `utils/`. +5. Keep changes minimal; add/update **Vitest** tests (`*.test.ts`) when behavior changes. +6. Run: `pnpm typecheck`, `pnpm lint`, `pnpm test` (and `pnpm format:check` if formatting changed). +7. All runtime artifacts go under `tmp/` (never commit them). **Scratch space:** Use `tmp/` for generated HTML/markdown/JSON/reports. @@ -31,6 +32,13 @@ - Install deps: `pnpm install` - Set `OPENAI_API_KEY` via env or `.env` (humans do this; agents must not read secrets) - If a task requires Playwright, follow the repo README for system deps +- If a task requires Python (e.g., `etf-backtest`), set up the venv: + ```bash + # On Debian/Ubuntu, install venv support first: sudo apt install python3-venv + python3 -m venv .venv + source .venv/bin/activate + pip install numpy pandas torch + ``` **Common scripts (see `package.json` for all):** @@ -86,6 +94,9 @@ All file tools are sandboxed to `tmp/` using path validation (`src/tools/utils/f - **`listFiles`** (`src/tools/list-files/list-files-tool.ts`) - Lists files/dirs under `tmp/`. - Params: `{ path?: string }` (defaults to `tmp/` root) +- **`runPython`** (`src/tools/run-python/run-python-tool.ts`) + - Runs a Python script from a configured scripts directory. + - Params: `{ scriptName: string }` ### Safe web fetch tool @@ -99,9 +110,15 @@ All file tools are sandboxed to `tmp/` using path validation (`src/tools/utils/f ## 5) Coding conventions (how changes should look) - Initialize `Logger` in CLI entry points and pass it into clients/pipelines via constructor options. +- Use `Logger` instead of `console.log`/`console.error` for output. - Prefer shared helpers in `src/utils` (`parse-args`, `question-handler`) over custom logic. +- `main.ts` should stay focused on the **basic agent flow**: argument parsing → agent setup → run loop → final output. Move helper logic into `clients/` or `utils/` - Prefer TypeScript path aliases over deep relative imports: `~tools/*`, `~clients/*`, `~utils/*`. - Use Zod schemas for CLI args and tool IO. +- Keep object field names in `camelCase` (e.g., `trainSamples`), not `snake_case`. +- Keep Zod schemas in a dedicated `schemas.ts` file for each CLI (avoid inline schemas in `main.ts`). +- Keep constants in a dedicated `constants.ts` file for each CLI. +- Move hardcoded numeric values into `constants.ts` (treat numbers as configuration). - For HTTP fetching in code, prefer `Fetch` (sanitized) or `PlaywrightScraper` for JS-heavy pages. - When adding tools that touch files, use `src/tools/utils/fs.ts` for path validation. - Comments should capture invariants or subtle behavior, not restate code. @@ -127,3 +144,7 @@ All file tools are sandboxed to `tmp/` using path validation (`src/tools/utils/f - [ ] Any generated artifacts are in `tmp/` only --- + +# ExecPlans + +When writing complex features or significant refactors, use an ExecPlan (as described in .agent/PLANS.md) from design to implementation. diff --git a/README.md b/README.md index 3ceff76..2aaca8b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,18 @@ A minimal TypeScript CLI sandbox for testing agent workflows and safe web scrapi 5. Run the demo: `pnpm run:guestbook` 6. (Optional) Explore Finnish name stats: `pnpm run:name-explorer -- --mode ai|stats` 7. (Optional) Run publication scraping: `pnpm run:scrape-publications -- --url="https://example.com"` +8. (Optional) Run ETF backtest: `pnpm run:etf-backtest` (requires Python setup below) + +### Python Setup (for ETF backtest) + +```bash +# On Debian/Ubuntu, install venv support first: +sudo apt install python3-venv + +python3 -m venv .venv +source .venv/bin/activate +pip install numpy pandas torch +``` ## Commands @@ -19,6 +31,7 @@ A minimal TypeScript CLI sandbox for testing agent workflows and safe web scrapi | `pnpm run:guestbook` | Run the interactive guestbook CLI demo | | `pnpm run:name-explorer` | Explore Finnish name statistics (AI Q&A or stats) | | `pnpm run:scrape-publications` | Scrape publication links and build a review page | +| `pnpm run:etf-backtest` | Run neural network ETF backtest (requires Python) | | `pnpm typecheck` | Run TypeScript type checking | | `pnpm lint` | Run ESLint for code quality | | `pnpm lint:fix` | Run ESLint and auto-fix issues | @@ -58,7 +71,7 @@ Outputs are written under `tmp/name-explorer/`, including `statistics.html` in s ## Tools -File tools are sandboxed to the `tmp/` directory with path validation to prevent traversal and symlink attacks. The `fetchUrl` tool adds SSRF protections and HTML sanitization. +File tools are sandboxed to the `tmp/` directory with path validation to prevent traversal and symlink attacks. The `fetchUrl` tool adds SSRF protections and HTML sanitization, and `runPython` executes whitelisted Python scripts from a configured directory. | Tool | Location | Description | | ----------- | ----------------------------------------- | ------------------------------------------------------- | @@ -66,12 +79,17 @@ File tools are sandboxed to the `tmp/` directory with path validation to prevent | `readFile` | `src/tools/read-file/read-file-tool.ts` | Reads file content from `tmp` directory | | `writeFile` | `src/tools/write-file/write-file-tool.ts` | Writes content to files in `tmp` directory | | `listFiles` | `src/tools/list-files/list-files-tool.ts` | Lists files and directories under `tmp` | +| `runPython` | `src/tools/run-python/run-python-tool.ts` | Runs Python scripts from a configured scripts directory | ## Project Structure ``` src/ ├── cli/ +│ ├── etf-backtest/ +│ │ ├── main.ts # ETF backtest CLI entry point +│ │ ├── README.md # ETF backtest docs +│ │ └── scripts/ # Python backtest + prediction scripts │ ├── guestbook/ │ │ ├── main.ts # Guestbook CLI entry point │ │ └── README.md # Guestbook CLI docs @@ -99,6 +117,7 @@ src/ │ ├── fetch-url/ # Safe fetch tool │ ├── list-files/ # List files tool │ ├── read-file/ # Read file tool +│ ├── run-python/ # Run Python scripts tool │ ├── write-file/ # Write file tool │ └── utils/ │ ├── fs.ts # Path safety utilities diff --git a/agent/PLANS.md b/agent/PLANS.md new file mode 100644 index 0000000..ccc7566 --- /dev/null +++ b/agent/PLANS.md @@ -0,0 +1,136 @@ +# ExecPlans for cli-agent-sandbox + +This repo is a minimal TypeScript CLI sandbox. ExecPlans exist to make larger changes safe, reproducible, and testable by a novice who only has the repo and the plan. Keep plans tailored to this repository, not a generic template. + +Use an ExecPlan only for complex features or significant refactors. For small, localized changes, skip the plan and just implement. + +## Non-negotiables + +- Self-contained: the plan must include all context needed to execute it without external docs or prior plans. +- Observable outcomes: describe what a human can run and see to prove the change works. +- Living document: update the plan as work proceeds; never let it drift from reality. +- Repo-safe: never read `.env`, never write outside the repo or `tmp/`, never commit or push. +- Minimal, test-covered changes: update or add Vitest tests when behavior changes. + +## Repository context to embed in every plan + +Include a short orientation paragraph naming the key paths and how they relate: + +- Entry points live in `src/cli//main.ts` with a matching `src/cli//README.md`. +- Pipelines and clients live in `src/cli//clients/*`; schemas in `src/cli//types/*`. +- Shared helpers: `src/utils/parse-args.ts`, `src/utils/question-handler.ts`, `src/clients/logger.ts`. +- Tool sandboxing is under `src/tools/*` and path validation in `src/tools/utils/fs.ts`. +- Runtime artifacts belong under `tmp/` only. + +If the plan adds a new CLI, state that it must be scaffolded via: + + pnpm scaffold:cli -- --name=my-cli --description="What it does" + +Then add `"run:my-cli": "tsx src/cli/my-cli/main.ts"` to `package.json`. + +## Repo conventions to capture in plans (when relevant) + +- Initialize `Logger` in CLI entry points and pass it into clients/pipelines via constructor options. +- Use Zod schemas for CLI args and tool IO; name the schema files in the plan. +- Prefer TypeScript path aliases like `~tools/*`, `~clients/*`, `~utils/*` over deep relative imports. +- Avoid `index.ts` barrel exports; use explicit module paths. +- For HTTP fetching, prefer sanitized `Fetch` or `PlaywrightScraper` as appropriate. +- Any file-touching tool must use path validation from `src/tools/utils/fs.ts`. + +## Required sections in every ExecPlan + +Use these headings, in this order, and keep them up to date: + +1. **Purpose / Big Picture** — what the user gains and how they can see it working. +2. **Progress** — checklist with timestamps (UTC), split partial work into “done” vs “remaining”. +3. **Surprises & Discoveries** — unexpected behaviors or constraints with short evidence. +4. **Decision Log** — decision, rationale, date/author. +5. **Outcomes & Retrospective** — what was achieved, gaps, lessons learned. +6. **Context and Orientation** — repo-specific orientation and key files. +7. **Conventions and Contracts** — logging, schemas, imports, and tool safety expectations. +8. **Plan of Work** — prose describing edits, with precise file paths and locations. +9. **Concrete Steps** — exact commands to run (cwd included) and expected short outputs. +10. **Validation and Acceptance** — behavioral acceptance and tests; name new tests. +11. **Idempotence and Recovery** — how to rerun safely; rollback guidance if needed. +12. **Artifacts and Notes** — concise transcripts, diffs, or snippets as indented blocks. +13. **Interfaces and Dependencies** — required modules, types, function signatures, and why. + +## Formatting rules + +- The ExecPlan is a normal Markdown document (no outer code fence). +- Prefer prose over lists; the only mandatory checklist is in **Progress**. +- Define any non-obvious term the first time you use it. +- Use repo-relative paths and exact function/module names. +- Do not point to external docs; embed the needed context in the plan itself. + +## Validation defaults for this repo + +State which of these apply, and include expected outcomes: + +- `pnpm typecheck` +- `pnpm lint` (or `pnpm lint:fix` if auto-fixing is intended) +- `pnpm test` +- `pnpm format:check` (if formatting changes) + +If the change affects a CLI, include a concrete CLI invocation and expected output. + +## ExecPlan skeleton (copy and fill) + + # + + This ExecPlan is a living document. Update **Progress**, **Surprises & Discoveries**, **Decision Log**, and **Outcomes & Retrospective** as work proceeds. + + ## Purpose / Big Picture + + Describe the user-visible behavior and how to observe it. + + ## Progress + + - [ ] (2026-01-25 00:00Z) Example incomplete step. + + ## Surprises & Discoveries + + - Observation: … + Evidence: … + + ## Decision Log + + - Decision: … + Rationale: … + Date/Author: … + + ## Outcomes & Retrospective + + Summarize results, gaps, and lessons learned. + + ## Context and Orientation + + Explain the relevant parts of `src/cli/...`, shared helpers, and tools. + + ## Conventions and Contracts + + Call out logging, Zod schemas, imports, and any tool safety expectations. + + ## Plan of Work + + Prose description of edits with precise file paths and locations. + + ## Concrete Steps + + State commands with cwd and short expected outputs. + + ## Validation and Acceptance + + Behavioral acceptance plus test commands and expectations. + + ## Idempotence and Recovery + + How to rerun safely and roll back if needed. + + ## Artifacts and Notes + + Short transcripts, diffs, or snippets as indented blocks. + + ## Interfaces and Dependencies + + Required types/modules/functions and why they exist. diff --git a/package.json b/package.json index bd0366e..e7a9c88 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "run:guestbook": "tsx src/cli/guestbook/main.ts", "run:name-explorer": "pnpm -s node:tsx -- src/cli/name-explorer/main.ts", "run:scrape-publications": "tsx src/cli/scrape-publications/main.ts", - "scaffold:cli": "pnpm -s node:tsx -- scripts/scaffold-cli.ts", + "run:etf-backtest": "tsx src/cli/etf-backtest/main.ts", + "scaffold:cli": "tsx scripts/scaffold-cli.ts", "node:tsx": "node --disable-warning=ExperimentalWarning --import tsx", "typecheck": "tsc --noEmit", "lint": "eslint .", diff --git a/scripts/scaffold-cli.ts b/scripts/scaffold-cli.ts index 40f43dd..1adb30f 100644 --- a/scripts/scaffold-cli.ts +++ b/scripts/scaffold-cli.ts @@ -4,12 +4,14 @@ * Scaffold a new CLI from the basic template. * * Usage: - * pnpm scaffold:cli -- --name=my-cli --description="My CLI description" - * pnpm scaffold:cli -- --name=my-cli # description defaults to "TODO: Add description" + * pnpm scaffold:cli --name=my-cli --description="My CLI description" + * pnpm scaffold:cli --name=my-cli # description defaults to "TODO: Add description" */ import fs from "node:fs/promises"; import path from "node:path"; -import { argv } from "zx"; +import { Logger } from "~clients/logger"; +import { parseArgs } from "~utils/parse-args"; +import { z } from "zod"; const TEMPLATE_DIR = path.join(process.cwd(), "templates", "cli-basic"); const CLI_DIR = path.join(process.cwd(), "src", "cli"); @@ -80,17 +82,14 @@ const copyTemplateFile = async ( }; const main = async (): Promise => { - const name = argv.name as string | undefined; - const description = - (argv.description as string | undefined) ?? "TODO: Add description"; + const logger = new Logger(); - if (!name) { - console.error("Error: --name is required"); - console.error( - 'Usage: pnpm scaffold:cli -- --name=my-cli --description="My description"' - ); - process.exit(1); - } + const argsSchema = z.object({ + name: z.string({ error: "Error: --name is required" }), + description: z.string().default("TODO: Add description"), + }); + + const { name, description } = parseArgs({ logger, schema: argsSchema }); validateCliName(name); diff --git a/src/cli/etf-backtest/README.md b/src/cli/etf-backtest/README.md new file mode 100644 index 0000000..5a1b853 --- /dev/null +++ b/src/cli/etf-backtest/README.md @@ -0,0 +1,156 @@ +# ETF Backtest + +Iterative feature selection optimization agent for realistic 12-month ETF return predictions. + +The agent selects price-only features, runs experiments, and optimizes for **prediction accuracy** (not trading performance). It uses non-overlapping evaluation windows for honest assessment. + +## Requirements + +- Python 3 with `numpy`, `pandas`, and `torch` installed (see repo README for setup) +- ETF data at `tmp/etf-backtest/data.json` + +## Run + +```bash +# Run optimization (default: 5 iterations max) +pnpm run:etf-backtest + +# With options +pnpm run:etf-backtest --ticker=SPY --maxIterations=5 --seed=42 --verbose +``` + +## Arguments + +| Argument | Default | Description | +| ----------------- | ------- | ------------------------------- | +| `--ticker` | `SPY` | ETF ticker symbol | +| `--maxIterations` | `5` | Maximum optimization iterations | +| `--seed` | `42` | Random seed for reproducibility | +| `--verbose` | `false` | Enable verbose logging | + +## Feature Menu + +The agent selects 8-12 features from these categories: + +| Category | Features | +| ----------- | -------------------------------------------------------- | +| Momentum | `mom_1m`, `mom_3m`, `mom_6m`, `mom_12m` | +| Trend | `px_sma50`, `px_sma200`, `sma50_sma200`, `dist_52w_high` | +| Risk | `vol_1m`, `vol_3m`, `vol_6m`, `dd_current`, `mdd_12m` | +| Oscillators | `rsi_14`, `bb_width` | + +## How It Works + +1. **Agent selects features** from the menu (starts with 8-12) +2. **Runs experiment** via `run_experiment.py` (backtest + prediction) +3. **Analyzes results**: R² (non-overlapping), direction accuracy, MAE +4. **Decides**: continue with tweaked features or stop +5. **Stops early** if no improvement for 2 iterations + +## Metrics + +### Prediction Accuracy (Primary - Optimization Target) + +| Metric | Description | +| ------------------------------------ | -------------------------------------------------- | +| `r2NonOverlapping` | R² on non-overlapping 12-month windows (honest) | +| `directionAccuracyNonOverlapping` | Sign prediction accuracy on independent periods | +| `mae` | Mean absolute error of 12-month return predictions | +| `calibrationRatio` | Predicted std / actual std (target: 0.8-1.2) | + +### Backtest Metrics (Informational Only) + +| Metric | Description | +| -------------- | -------------------------------------- | +| `sharpe` | Sharpe ratio of daily trading strategy | +| `maxDrawdown` | Maximum peak-to-trough decline | +| `cagr` | Compound annual growth rate | + +### Why Non-Overlapping? + +With 252-day (12-month) forward targets, consecutive data points overlap by 99.6%. This inflates apparent R² because the model sees nearly identical targets. Non-overlapping evaluation uses truly independent periods (~10 samples per decade) for realistic performance assessment. + +## Output + +``` +============================================================ +OPTIMIZATION COMPLETE +============================================================ +Iterations: 3 +Best iteration: 2 +Stop reason: No improvement for 2 consecutive iterations + +Best Feature Set: + - mom_1m + - mom_3m + - vol_1m + - px_sma50 + ... + +Prediction Accuracy (Non-Overlapping - Honest Assessment): + R²: 0.045 + Direction Accuracy: 60.0% + Independent Samples: 10 + +Prediction Accuracy (Overlapping - Inflated): + R²: 0.152 + Direction Accuracy: 58.5% + MAE: 12.3% + Calibration: 0.95 + +Backtest Metrics (Informational): + Sharpe Ratio: 0.85 + Max Drawdown: -18.5% + CAGR: 12.3% + +12-Month Prediction: + Expected Return: 8.5% + 95% CI: [-12.5%, 29.5%] + +Uncertainty Details: + Base Std: 8.2% + Adjusted Std: 10.5% + Extrapolation: Yes (features outside training range) + +Confidence: MODERATE +Note: Non-overlapping metrics use only 10 independent periods. +Past performance does not guarantee future results. +============================================================ +``` + +## Scripts + +| Script | Purpose | +| ------------------- | ------------------------------------------------- | +| `run_experiment.py` | Unified experiment runner (backtest + prediction) | +| `shared.py` | Feature registry and model training utilities | +| `backtest.py` | Legacy: standalone backtest | +| `predict.py` | Legacy: standalone prediction | + +## Uncertainty Estimation + +The 95% confidence interval uses adjusted uncertainty that accounts for: + +1. **Base uncertainty**: Standard deviation of test set residuals +2. **Extrapolation penalty**: Increased when current features are >2 std from training mean +3. **Market floor**: Minimum 10% std (12-month returns are inherently uncertain) + +## Pitfall Avoidance + +- **Overlapping windows**: Evaluation uses non-overlapping periods for honest metrics +- **Lookahead**: Signal at t → position at t+1 +- **Data leakage**: Standardize using train mean/std only +- **No shuffle**: Chronological train/val/test split +- **Extrapolation**: Confidence intervals widen when features are outside training range + +## Data Format + +Expects `tmp/etf-backtest/data.json`: + +```json +{ + "series": [ + { "date": "YYYY-MM-DD", "value": { "raw": } } + ] +} +``` diff --git a/src/cli/etf-backtest/constants.ts b/src/cli/etf-backtest/constants.ts new file mode 100644 index 0000000..52d9f2a --- /dev/null +++ b/src/cli/etf-backtest/constants.ts @@ -0,0 +1,84 @@ +import path from "node:path"; + +export const DEFAULT_VERBOSE = false; +export const DEFAULT_TICKER = "SPY"; +export const DEFAULT_MAX_ITERATIONS = 5; +export const DEFAULT_SEED = 42; + +export const AGENT_NAME = "EtfFeatureOptimizer"; +export const MODEL_NAME = "gpt-4o-mini"; + +export const MAX_NO_IMPROVEMENT = 2; +export const ZERO = 0; +export const MAX_TURNS_PER_ITERATION = 3; +export const TOOL_RESULT_PREVIEW_LIMIT = 300; +export const REASONING_PREVIEW_LIMIT = 100; + +export const MIN_FEATURES = 8; +export const MAX_FEATURES = 12; +export const PREDICTION_HORIZON_MONTHS = 12; +export const OVERLAP_PERCENT = 99; +export const SAMPLES_PER_DECADE = 10; +export const CI_LEVEL_PERCENT = 95; + +export const TARGET_R2_NON_OVERLAPPING = 0.05; +export const TARGET_DIR_ACC_NON_OVERLAPPING = 0.55; +export const TARGET_CALIBRATION_MIN = 0.8; +export const TARGET_CALIBRATION_MAX = 1.2; + +export const SCORE_WEIGHTS = { + r2NonOverlapping: 2, + directionAccuracyNonOverlapping: 1, + mae: -2, +} as const; + +export const NEGATIVE_SHARPE_THRESHOLD = 0; +export const NEGATIVE_SHARPE_PENALTY = -0.5; + +export const CONFIDENCE_THRESHOLDS = { + moderate: { + r2NonOverlapping: 0.03, + directionAccuracyNonOverlapping: 0.5, + maxCiWidth: 0.5, + }, + reasonable: { + r2NonOverlapping: 0.08, + directionAccuracyNonOverlapping: 0.6, + maxCiWidth: 0.4, + }, +} as const; + +export const PERCENT_MULTIPLIER = 100; + +export const DECIMAL_PLACES = { + r2: 3, + percent: 1, + calibration: 2, + sharpe: 2, + cagr: 1, + score: 3, +} as const; + +export const LINE_WIDTH = 60; +export const LINE_SEPARATOR = "=".repeat(LINE_WIDTH); + +export const NO_IMPROVEMENT_REASON = `No improvement for ${MAX_NO_IMPROVEMENT} consecutive iterations`; + +export const INDEX_NOT_FOUND = -1; +export const JSON_SLICE_END_OFFSET = 1; + +export const SCRIPTS_DIR = path.join( + process.cwd(), + "src", + "cli", + "etf-backtest", + "scripts" +); +export const PYTHON_BINARY = path.join(process.cwd(), ".venv", "bin", "python3"); + +export const FEATURE_MENU = { + momentum: ["mom_1m", "mom_3m", "mom_6m", "mom_12m"], + trend: ["px_sma50", "px_sma200", "sma50_sma200", "dist_52w_high"], + risk: ["vol_1m", "vol_3m", "vol_6m", "dd_current", "mdd_12m"], + oscillators: ["rsi_14", "bb_width"], +} as const; diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts new file mode 100644 index 0000000..42ce9f9 --- /dev/null +++ b/src/cli/etf-backtest/main.ts @@ -0,0 +1,532 @@ +// pnpm run:etf-backtest + +// Iterative ETF feature selection optimization agent +// Runs experiments with different feature combinations and finds the best set + +import "dotenv/config"; + +import { Agent, MemorySession, Runner } from "@openai/agents"; +import { Logger } from "~clients/logger"; +import { createRunPythonTool } from "~tools/run-python/run-python-tool"; +import { parseArgs } from "~utils/parse-args"; + +import { + AGENT_NAME, + CI_LEVEL_PERCENT, + CONFIDENCE_THRESHOLDS, + DECIMAL_PLACES, + FEATURE_MENU, + INDEX_NOT_FOUND, + JSON_SLICE_END_OFFSET, + LINE_SEPARATOR, + MAX_FEATURES, + MAX_NO_IMPROVEMENT, + MAX_TURNS_PER_ITERATION, + MIN_FEATURES, + MODEL_NAME, + NEGATIVE_SHARPE_PENALTY, + NEGATIVE_SHARPE_THRESHOLD, + NO_IMPROVEMENT_REASON, + OVERLAP_PERCENT, + PERCENT_MULTIPLIER, + PREDICTION_HORIZON_MONTHS, + PYTHON_BINARY, + REASONING_PREVIEW_LIMIT, + SAMPLES_PER_DECADE, + SCORE_WEIGHTS, + SCRIPTS_DIR, + TARGET_CALIBRATION_MAX, + TARGET_CALIBRATION_MIN, + TARGET_DIR_ACC_NON_OVERLAPPING, + TARGET_R2_NON_OVERLAPPING, + TOOL_RESULT_PREVIEW_LIMIT, + ZERO, +} from "./constants"; +import { + AgentOutputSchema, + CliArgsSchema, + ExperimentResultSchema, +} from "./schemas"; +import type { ExperimentResult } from "./schemas"; + +const logger = new Logger(); + +// --- Parse CLI arguments --- +const { verbose, ticker, maxIterations, seed } = parseArgs({ + logger, + schema: CliArgsSchema, +}); + +const formatPercent = ( + value: number, + decimals = DECIMAL_PLACES.percent +): string => `${(value * PERCENT_MULTIPLIER).toFixed(decimals)}%`; + +const formatFixed = (value: number, decimals: number): string => + value.toFixed(decimals); + +// --- Build agent instructions --- +const buildInstructions = () => ` +You are an ETF feature selection optimization agent. Your goal is to find features that produce **accurate ${PREDICTION_HORIZON_MONTHS}-month return predictions**, not optimal trading strategies. + +## Important Distinction +- **Prediction accuracy** (R², direction accuracy, MAE) = Can we forecast the ${PREDICTION_HORIZON_MONTHS}-month return? +- **Trading performance** (Sharpe, drawdown) = Is this a good trading strategy? + +You are optimizing for PREDICTION ACCURACY. Trading metrics are informational only. + +## Feature Menu +Choose ${MIN_FEATURES}-${MAX_FEATURES} features from the following categories: + +**Momentum (price-based returns over periods):** +${FEATURE_MENU.momentum.map((f) => `- ${f}`).join("\n")} + +**Trend (price relative to moving averages):** +${FEATURE_MENU.trend.map((f) => `- ${f}`).join("\n")} + +**Risk (volatility and drawdown measures):** +${FEATURE_MENU.risk.map((f) => `- ${f}`).join("\n")} + +**Oscillators (optional, technical indicators):** +${FEATURE_MENU.oscillators.map((f) => `- ${f}`).join("\n")} + +## Metrics Priority (most to least important) +1. **r2NonOverlapping** - R² on non-overlapping ${PREDICTION_HORIZON_MONTHS}-month windows (honest assessment). Target > ${TARGET_R2_NON_OVERLAPPING} +2. **directionAccuracyNonOverlapping** - Did we predict the sign correctly? Target > ${formatPercent( + TARGET_DIR_ACC_NON_OVERLAPPING +)} +3. **mae** - Mean absolute error of predictions. Lower is better +4. **calibrationRatio** - Is predicted magnitude realistic? Target ${TARGET_CALIBRATION_MIN}-${TARGET_CALIBRATION_MAX} + +## Non-Overlapping vs Overlapping Metrics +- **Non-overlapping metrics** use truly independent ${PREDICTION_HORIZON_MONTHS}-month periods (~${SAMPLES_PER_DECADE} samples per decade) +- **Overlapping metrics** use all data but windows overlap ${OVERLAP_PERCENT}%, inflating apparent performance +- Focus on NON-OVERLAPPING metrics for realistic assessment + +## Backtest Metrics (informational only) +- Sharpe ratio: If negative, features may be problematic (sanity check) +- Max drawdown: Not an optimization target + +## Feature Selection Guidelines +For ${PREDICTION_HORIZON_MONTHS}-month predictions: +- **Momentum features** (mom_*) capture recent trends but may mean-revert over ${PREDICTION_HORIZON_MONTHS} months +- **Trend features** (px_sma*, sma50_sma200) show long-term direction +- **Risk features** (vol_*, dd_*, mdd_*) capture volatility regimes + +Be skeptical of high R² values - with overlapping windows, apparent fit is inflated. +Focus on features with economic intuition for ${PREDICTION_HORIZON_MONTHS}-month horizons. + +## Tool Usage +IMPORTANT: Run exactly ONE experiment per turn. Do not run multiple experiments. + +Call runPython with: +- scriptName: "run_experiment.py" +- input: { "ticker": "", "featureIds": [...], "seed": } + +After you receive results, respond with your analysis. Do not call runPython again in the same turn. + +## Response Format +After each experiment, respond with JSON (do not call any more tools): +{ + "status": "continue" | "final", + "selectedFeatures": ["feature1", "feature2", ...], + "reasoning": "Explain your analysis focusing on prediction accuracy", + "stopReason": "Explain why stopping if final, otherwise null" +} +`; + +// --- Compute improvement score --- +const computeScore = (metrics: ExperimentResult["metrics"]): number => { + // Primary: prediction accuracy on non-overlapping samples (honest assessment) + // Secondary: Sharpe < 0 is a red flag (sanity check only) + return ( + metrics.r2NonOverlapping * SCORE_WEIGHTS.r2NonOverlapping + + metrics.directionAccuracyNonOverlapping * + SCORE_WEIGHTS.directionAccuracyNonOverlapping + + metrics.mae * SCORE_WEIGHTS.mae + + (metrics.sharpe < NEGATIVE_SHARPE_THRESHOLD + ? NEGATIVE_SHARPE_PENALTY + : ZERO) + ); +}; + +// --- Print final results --- +const printFinalResults = ( + bestResult: ExperimentResult, + bestIteration: number, + totalIterations: number, + stopReason: string +) => { + // Confidence note based on non-overlapping metrics + const ciWidth = + bestResult.prediction.ci95High - bestResult.prediction.ci95Low; + let confidence = "LOW"; + if ( + bestResult.metrics.r2NonOverlapping > + CONFIDENCE_THRESHOLDS.moderate.r2NonOverlapping && + bestResult.metrics.directionAccuracyNonOverlapping > + CONFIDENCE_THRESHOLDS.moderate.directionAccuracyNonOverlapping && + ciWidth < CONFIDENCE_THRESHOLDS.moderate.maxCiWidth + ) { + confidence = "MODERATE"; + } + if ( + bestResult.metrics.r2NonOverlapping > + CONFIDENCE_THRESHOLDS.reasonable.r2NonOverlapping && + bestResult.metrics.directionAccuracyNonOverlapping > + CONFIDENCE_THRESHOLDS.reasonable.directionAccuracyNonOverlapping && + ciWidth < CONFIDENCE_THRESHOLDS.reasonable.maxCiWidth + ) { + confidence = "REASONABLE"; + } + + const lines = [ + "", + LINE_SEPARATOR, + "OPTIMIZATION COMPLETE", + LINE_SEPARATOR, + `Iterations: ${totalIterations}`, + `Best iteration: ${bestIteration}`, + `Stop reason: ${stopReason}`, + "", + "Best Feature Set:", + ...bestResult.featureIds.map((feature) => ` - ${feature}`), + "", + "Prediction Accuracy (Non-Overlapping - Honest Assessment):", + ` R²: ${formatFixed( + bestResult.metrics.r2NonOverlapping, + DECIMAL_PLACES.r2 + )}`, + ` Direction Accuracy: ${formatPercent( + bestResult.metrics.directionAccuracyNonOverlapping + )}`, + ` Independent Samples: ${bestResult.dataInfo.nonOverlappingSamples}`, + "", + "Prediction Accuracy (Overlapping - Inflated):", + ` R²: ${formatFixed( + bestResult.metrics.r2, + DECIMAL_PLACES.r2 + )}`, + ` Direction Accuracy: ${formatPercent( + bestResult.metrics.directionAccuracy + )}`, + ` MAE: ${formatPercent(bestResult.metrics.mae)}`, + ` Calibration: ${formatFixed( + bestResult.metrics.calibrationRatio, + DECIMAL_PLACES.calibration + )}`, + "", + "Backtest Metrics (Informational):", + ` Sharpe Ratio: ${formatFixed( + bestResult.metrics.sharpe, + DECIMAL_PLACES.sharpe + )}`, + ` Max Drawdown: ${formatPercent(bestResult.metrics.maxDrawdown)}`, + ` CAGR: ${formatPercent( + bestResult.metrics.cagr, + DECIMAL_PLACES.cagr + )}`, + "", + `${PREDICTION_HORIZON_MONTHS}-Month Prediction:`, + ` Expected Return: ${formatPercent(bestResult.prediction.pred12mReturn)}`, + ` ${CI_LEVEL_PERCENT}% CI: [${formatPercent( + bestResult.prediction.ci95Low + )}, ${formatPercent(bestResult.prediction.ci95High)}]`, + "", + "Uncertainty Details:", + ` Base Std: ${formatPercent( + bestResult.prediction.uncertainty.baseStd + )}`, + ` Adjusted Std: ${formatPercent( + bestResult.prediction.uncertainty.adjustedStd + )}`, + ` Extrapolation: ${ + bestResult.prediction.uncertainty.isExtrapolating + ? "Yes (features outside training range)" + : "No" + }`, + "", + `Confidence: ${confidence}`, + `Note: Non-overlapping metrics use only ${bestResult.dataInfo.nonOverlappingSamples} independent periods.`, + "Past performance does not guarantee future results.", + LINE_SEPARATOR, + ]; + + logger.info(lines.join("\n")); +}; + +// --- Run iterative optimization --- +const runOptimization = async () => { + const runPythonTool = createRunPythonTool({ + scriptsDir: SCRIPTS_DIR, + logger, + pythonBinary: PYTHON_BINARY, + }); + + const agent = new Agent({ + name: AGENT_NAME, + model: MODEL_NAME, + tools: [runPythonTool], + outputType: AgentOutputSchema, + instructions: buildInstructions(), + }); + + const runner = new Runner(); + const session = new MemorySession(); + + // Tool logging + const toolsInProgress = new Set(); + runner.on("agent_tool_start", (_context, _agent, tool, details) => { + const toolCall = details.toolCall as Record; + const callId = toolCall.id as string; + if (toolsInProgress.has(callId)) { + return; + } + toolsInProgress.add(callId); + logger.tool(`Calling ${tool.name}`); + }); + runner.on("agent_tool_end", (_context, _agent, tool, result) => { + logger.tool(`${tool.name} completed`); + if (verbose) { + const preview = + result.length > TOOL_RESULT_PREVIEW_LIMIT + ? result.substring(ZERO, TOOL_RESULT_PREVIEW_LIMIT) + "..." + : result; + logger.debug(`Result: ${preview}`); + } + }); + + // Track state + let bestResult: ExperimentResult | null = null; + let bestIteration = ZERO; + let bestScore = Number.NEGATIVE_INFINITY; + let noImprovementCount = ZERO; + let iteration = ZERO; + let stopReason = "Max iterations reached"; + + // Initial prompt + let currentPrompt = ` +Start feature selection optimization for ${ticker}. + +Begin by selecting ${MIN_FEATURES}-${MAX_FEATURES} features that you think will best predict ${PREDICTION_HORIZON_MONTHS}-month returns. +Consider using a mix from each category (momentum, trend, risk). + +Use runPython with: +- scriptName: "run_experiment.py" +- input: { "ticker": "${ticker}", "featureIds": [...your features...], "seed": ${seed} } + +After running the experiment, analyze the results and decide whether to continue or stop. +`; + + while (iteration < maxIterations) { + iteration++; + logger.info(`\n--- Iteration ${iteration}/${maxIterations} ---`); + + let runResult; + try { + runResult = await runner.run(agent, currentPrompt, { + session, + maxTurns: MAX_TURNS_PER_ITERATION, // Limit turns per iteration: 1 tool call + 1 result + 1 output + }); + } catch (err) { + // Handle MaxTurnsExceededError - try to extract result from partial state + if ( + err && + typeof err === "object" && + "state" in err && + err.state && + typeof err.state === "object" && + "_newItems" in err.state + ) { + logger.warn("Agent exceeded turn limit, extracting partial results..."); + const state = err.state as { + _newItems?: { type: string; output?: unknown }[]; + }; + const partialResult = extractLastExperimentResult({ + newItems: state._newItems, + }); + if (partialResult) { + const score = computeScore(partialResult.metrics); + if (score > bestScore) { + bestScore = score; + bestResult = partialResult; + bestIteration = iteration; + } + } + currentPrompt = + "You ran too many experiments in one turn. Please run exactly ONE experiment, then respond with your JSON analysis."; + continue; + } + throw err; + } + const parseResult = AgentOutputSchema.safeParse(runResult.finalOutput); + + if (!parseResult.success) { + logger.warn("Invalid agent response format, continuing..."); + if (verbose) { + logger.debug(`Parse error: ${JSON.stringify(parseResult.error)}`); + } + currentPrompt = + "Your response was not valid JSON. Please respond with the correct format."; + continue; + } + + const output = parseResult.data; + logger.info(`Features: ${output.selectedFeatures.join(", ")}`); + logger.info( + `Reasoning: ${output.reasoning.substring(ZERO, REASONING_PREVIEW_LIMIT)}...` + ); + + // Try to extract experiment result from the tool call outputs + const lastToolResult = extractLastExperimentResult(runResult); + + if (lastToolResult) { + const score = computeScore(lastToolResult.metrics); + logger.info( + `Prediction: R²_no=${formatFixed( + lastToolResult.metrics.r2NonOverlapping, + DECIMAL_PLACES.r2 + )}, ` + + `DirAcc_no=${formatPercent( + lastToolResult.metrics.directionAccuracyNonOverlapping + )}, ` + + `MAE=${formatPercent( + lastToolResult.metrics.mae + )}, Score=${formatFixed(score, DECIMAL_PLACES.score)}` + ); + if (verbose) { + logger.debug( + `Backtest: Sharpe=${formatFixed( + lastToolResult.metrics.sharpe, + DECIMAL_PLACES.sharpe + )}, ` + `MaxDD=${formatPercent(lastToolResult.metrics.maxDrawdown)}` + ); + } + + if (score > bestScore) { + bestScore = score; + bestResult = lastToolResult; + bestIteration = iteration; + noImprovementCount = ZERO; + logger.info("New best result!"); + } else { + noImprovementCount++; + logger.info( + `No improvement (${noImprovementCount}/${MAX_NO_IMPROVEMENT})` + ); + } + } + + // Check stop conditions + if (output.status === "final") { + stopReason = output.stopReason ?? "Agent decided to stop"; + logger.info(`Agent stopped: ${stopReason}`); + break; + } + + if (noImprovementCount >= MAX_NO_IMPROVEMENT) { + stopReason = NO_IMPROVEMENT_REASON; + logger.info(stopReason); + break; + } + + // Build next prompt + currentPrompt = ` +Your previous experiment is complete. Results are in your conversation history. +You have ${maxIterations - iteration} iterations remaining. + +Based on the metrics, decide: +- If you want to try different features, select them and run another experiment +- If you think you've found a good set, respond with status "final" + +Focus on: Higher r2NonOverlapping, higher directionAccuracyNonOverlapping, lower MAE. +Backtest metrics (Sharpe, drawdown) are informational only. +`; + } + + // Output final results + if (bestResult) { + printFinalResults(bestResult, bestIteration, iteration, stopReason); + } else { + logger.warn("No successful experiments completed."); + } +}; + +// Extract JSON object from stdout which may contain other output before/after +const extractJsonFromStdout = (stdout: string): unknown => { + // Find the first '{' and match to its closing '}' + const startIdx = stdout.indexOf("{"); + if (startIdx === INDEX_NOT_FOUND) { + return null; + } + + let braceCount = ZERO; + let endIdx = INDEX_NOT_FOUND; + for (let i = startIdx; i < stdout.length; i++) { + if (stdout[i] === "{") { + braceCount++; + } + if (stdout[i] === "}") { + braceCount--; + } + if (braceCount === ZERO) { + endIdx = i; + break; + } + } + + if (endIdx === INDEX_NOT_FOUND) { + return null; + } + + const jsonStr = stdout.slice(startIdx, endIdx + JSON_SLICE_END_OFFSET); + return JSON.parse(jsonStr); +}; + +// Helper to extract experiment result from runner result +const extractLastExperimentResult = (runResult: { + newItems?: { type: string; output?: unknown }[]; +}): ExperimentResult | null => { + try { + // Look through the newItems for tool call outputs + const items = runResult.newItems ?? []; + for (const item of items) { + if (item.type === "tool_call_output_item" && item.output) { + const output = item.output; + // Output may be a string (JSON) or already parsed object + let parsed: unknown; + if (typeof output === "string") { + parsed = JSON.parse(output); + } else { + parsed = output; + } + + // The Python tool returns { success, exitCode, stdout, stderr } + const toolResult = parsed as { stdout?: string }; + if (toolResult.stdout) { + // Extract JSON from stdout which may contain training output before the result + const result = extractJsonFromStdout(toolResult.stdout); + if (result) { + const validated = ExperimentResultSchema.safeParse(result); + if (validated.success) { + return validated.data; + } + } + } + } + } + } catch { + // Parsing failed, return null + } + return null; +}; + +// --- Main --- +logger.info("ETF Backtest Feature Optimization starting..."); +if (verbose) { + logger.debug("Verbose mode enabled"); +} + +await runOptimization(); + +logger.info("\nETF Backtest completed."); diff --git a/src/cli/etf-backtest/schemas.ts b/src/cli/etf-backtest/schemas.ts new file mode 100644 index 0000000..2878b51 --- /dev/null +++ b/src/cli/etf-backtest/schemas.ts @@ -0,0 +1,68 @@ +import { z } from "zod"; + +import { + DEFAULT_MAX_ITERATIONS, + DEFAULT_SEED, + DEFAULT_TICKER, + DEFAULT_VERBOSE, +} from "./constants"; + +export const CliArgsSchema = z.object({ + verbose: z.coerce.boolean().default(DEFAULT_VERBOSE), + ticker: z.string().default(DEFAULT_TICKER), + maxIterations: z.coerce.number().default(DEFAULT_MAX_ITERATIONS), + seed: z.coerce.number().default(DEFAULT_SEED), +}); + +export type CliArgs = z.infer; + +export const AgentOutputSchema = z.object({ + status: z.enum(["continue", "final"]), + selectedFeatures: z.array(z.string()), + reasoning: z.string(), + stopReason: z.string().nullable(), +}); + +export type AgentOutput = z.infer; + +export const ExperimentResultSchema = z.object({ + featureIds: z.array(z.string()), + metrics: z.object({ + // Backtest metrics (informational) + sharpe: z.number(), + maxDrawdown: z.number(), + cagr: z.number(), + // Prediction metrics (overlapping) + r2: z.number(), + mse: z.number(), + directionAccuracy: z.number(), + mae: z.number(), + calibrationRatio: z.number(), + // Non-overlapping metrics (honest assessment) + r2NonOverlapping: z.number(), + directionAccuracyNonOverlapping: z.number(), + }), + prediction: z.object({ + pred12mReturn: z.number(), + ci95Low: z.number(), + ci95High: z.number(), + uncertainty: z.object({ + baseStd: z.number(), + adjustedStd: z.number(), + extrapolationMultiplier: z.number(), + isExtrapolating: z.boolean(), + }), + }), + modelInfo: z.object({ + trainSamples: z.number(), + valSamples: z.number(), + testSamples: z.number(), + }), + dataInfo: z.object({ + totalSamples: z.number(), + nonOverlappingSamples: z.number(), + effectiveIndependentPeriods: z.number(), + }), +}); + +export type ExperimentResult = z.infer; diff --git a/src/cli/etf-backtest/scripts/backtest.py b/src/cli/etf-backtest/scripts/backtest.py new file mode 100644 index 0000000..125581f --- /dev/null +++ b/src/cli/etf-backtest/scripts/backtest.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +""" +Minimal Neural Network ETF Backtest + +A self-contained backtest using a PyTorch MLP to predict next-day returns. +Designed to be readable and avoid common pitfalls: + 1. Lookahead bias: signal at t -> position at t+1 + 2. Data leakage: standardize using train stats only + 3. Time series shuffling: chronological split, no random shuffle +""" + +import numpy as np +import pandas as pd +import torch +from pathlib import Path + +from shared import ( + load_data, + build_base_features, + get_feature_cols, + split_data, + standardize, + train_model, +) + +# === CONFIG === +DATA_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "data.json" +COST_BPS = 5 # transaction cost in basis points + + +# === FEATURE ENGINEERING === +def build_features(df: pd.DataFrame) -> pd.DataFrame: + """Build feature matrix with next-day return as target.""" + df = build_base_features(df) + + # Label: next-day return (shift -1) + df["target"] = df["ret"].shift(-1) + + # Drop rows with NaN + df = df.dropna().reset_index(drop=True) + + return df + + +# === BACKTEST === +def backtest(test_df: pd.DataFrame, predictions: np.ndarray) -> pd.DataFrame: + """ + Run backtest with 1-day lag and transaction costs. + Signal at t -> position at t+1 (avoids lookahead). + """ + df = test_df.copy() + df["pred"] = predictions + + # Signal: pred > 0 -> want to be long + df["signal"] = (df["pred"] > 0).astype(int) + + # Position: apply signal with 1-day lag (position at t+1) + df["position"] = df["signal"].shift(1).fillna(0) + + # Strategy returns + df["strat_ret"] = df["position"] * df["target"] + + # Transaction costs: cost when position changes + df["trade"] = df["position"].diff().abs().fillna(0) + df["cost"] = df["trade"] * (COST_BPS / 10000) + df["strat_ret_net"] = df["strat_ret"] - df["cost"] + + # Equity curve (growth of $1) + df["equity"] = (1 + df["strat_ret_net"]).cumprod() + + return df + + +# === METRICS === +def compute_metrics(df: pd.DataFrame) -> dict: + """Compute backtest performance metrics.""" + returns = df["strat_ret_net"].values + equity = df["equity"].values + + # Total return + total_return = equity[-1] / equity[0] - 1 + + # CAGR (252 trading days) + n_days = len(returns) + years = n_days / 252 + cagr = (equity[-1] ** (1 / years)) - 1 if years > 0 else 0 + + # Annualized volatility + ann_vol = returns.std() * np.sqrt(252) + + # Sharpe ratio (risk-free = 0) + sharpe = (returns.mean() * 252) / ann_vol if ann_vol > 0 else 0 + + # Max drawdown + peak = np.maximum.accumulate(equity) + drawdown = (equity - peak) / peak + max_dd = drawdown.min() + + # Calmar ratio + calmar = cagr / abs(max_dd) if max_dd != 0 else 0 + + return { + "total_return": total_return, + "cagr": cagr, + "ann_volatility": ann_vol, + "sharpe": sharpe, + "max_drawdown": max_dd, + "calmar": calmar, + } + + +def print_metrics(metrics: dict): + """Print metrics in a readable format.""" + print("\n" + "=" * 40) + print("BACKTEST RESULTS") + print("=" * 40) + print(f"Total Return: {metrics['total_return']:>10.2%}") + print(f"CAGR: {metrics['cagr']:>10.2%}") + print(f"Ann. Volatility: {metrics['ann_volatility']:>10.2%}") + print(f"Sharpe Ratio: {metrics['sharpe']:>10.2f}") + print(f"Max Drawdown: {metrics['max_drawdown']:>10.2%}") + print(f"Calmar Ratio: {metrics['calmar']:>10.2f}") + print("=" * 40) + + +# === MAIN === +def main(): + print("Loading data...") + df = load_data(DATA_PATH) + print(f" Loaded {len(df)} rows ({df['date'].min()} to {df['date'].max()})") + + print("Building features...") + df = build_features(df) + print(f" Features built, {len(df)} rows after dropping NaN") + + print("Splitting data...") + train, val, test = split_data(df) + print(f" Train: {len(train)}, Val: {len(val)}, Test: {len(test)}") + + print("Standardizing features...") + feature_cols = get_feature_cols() + X_train, X_val, X_test, y_train, y_val, y_test, _, _ = standardize( + train, val, test, feature_cols + ) + print(f" {len(feature_cols)} features: {feature_cols[:3]}...") + + print("Training model...") + model = train_model(X_train, y_train, X_val, y_val) + + print("Generating predictions on test set...") + device = next(model.parameters()).device + X_test_t = torch.tensor(X_test, dtype=torch.float32, device=device) + model.eval() + with torch.no_grad(): + predictions = model(X_test_t).cpu().numpy() + + print("Running backtest...") + results = backtest(test, predictions) + + metrics = compute_metrics(results) + print_metrics(metrics) + + # Print pitfall avoidance notes + print("\nPITFALL AVOIDANCE:") + print(" 1. Lookahead: signal at t -> position at t+1") + print(" 2. Leakage: standardized with train mean/std only") + print(" 3. No shuffle: chronological train/val/test split") + + +if __name__ == "__main__": + main() diff --git a/src/cli/etf-backtest/scripts/predict.py b/src/cli/etf-backtest/scripts/predict.py new file mode 100644 index 0000000..022dbb3 --- /dev/null +++ b/src/cli/etf-backtest/scripts/predict.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +12-Month ETF Return Prediction + +Predicts cumulative returns for the next ~252 trading days (12 months) +from the most recent data point using a PyTorch MLP. + +Outputs prediction with confidence intervals to tmp/etf-backtest/prediction.json. +""" + +import json +import numpy as np +import pandas as pd +import torch +from pathlib import Path +from datetime import datetime + +from shared import ( + load_data, + build_base_features, + get_feature_cols, + split_data, + standardize, + train_model, +) + +# === CONFIG === +DATA_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "data.json" +OUTPUT_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "prediction.json" + +FORWARD_DAYS = 252 # ~12 months of trading days + + +# === FEATURE ENGINEERING === +def build_prediction_features(df: pd.DataFrame) -> pd.DataFrame: + """Build feature matrix with 252-day forward return as target.""" + df = build_base_features(df) + + # Target: cumulative return over next 252 trading days + df["target"] = df["price"].shift(-FORWARD_DAYS) / df["price"] - 1 + + # Drop rows with NaN (keeps only rows where we have forward return data) + df = df.dropna().reset_index(drop=True) + + return df + + +def get_latest_features(df_raw: pd.DataFrame, feature_cols: list[str]) -> tuple[np.ndarray, str]: + """ + Get features for the most recent data point (for forward prediction). + Uses raw data with base features, not the training df (which excludes recent rows). + """ + df = build_base_features(df_raw) + df = df.dropna(subset=feature_cols) + + if len(df) == 0: + raise ValueError("No valid feature rows after processing") + + latest = df.iloc[-1] + latest_date = latest["date"].strftime("%Y-%m-%d") + features = latest[feature_cols].values.astype(np.float64) + + return features, latest_date + + +def estimate_uncertainty(model, X_test: np.ndarray, y_test: np.ndarray) -> float: + """ + Estimate prediction uncertainty using test set residuals. + Returns standard deviation of prediction errors for confidence intervals. + """ + device = next(model.parameters()).device + X_test_t = torch.tensor(X_test, dtype=torch.float32, device=device) + + model.eval() + with torch.no_grad(): + preds = model(X_test_t).cpu().numpy() + + residuals = y_test - preds + return float(residuals.std()) + + +def compute_test_metrics(y_test: np.ndarray, predictions: np.ndarray) -> dict: + """Compute R² and MSE on test set.""" + mse = float(np.mean((y_test - predictions) ** 2)) + ss_res = np.sum((y_test - predictions) ** 2) + ss_tot = np.sum((y_test - y_test.mean()) ** 2) + r2 = float(1 - ss_res / ss_tot) if ss_tot > 0 else 0.0 + return {"mse": mse, "r2": r2} + + +def print_prediction(result: dict): + """Print prediction in a readable format.""" + print("\n" + "=" * 50) + print("12-MONTH RETURN PREDICTION") + print("=" * 50) + print(f"Prediction Date: {result['prediction_date']}") + print(f"Horizon: {result['horizon_days']} trading days (~12 months)") + print() + print(f"Predicted Return: {result['predicted_return_pct']:>+.1f}%") + ci = result["confidence_interval_95"] + print(f"95% Confidence: {ci['low']:>+.1f}% to {ci['high']:>+.1f}%") + print() + print("Model Quality:") + model_info = result["model_info"] + print(f" Test R²: {model_info['test_r2']:.3f}") + print(f" Test MSE: {model_info['test_mse']:.6f}") + print(f" Training samples: {model_info['train_samples']}") + print() + print("IMPORTANT CAVEATS:") + for caveat in result["caveats"]: + print(f" - {caveat}") + print("=" * 50) + + +# === MAIN === +def main(): + print("Loading data...") + df_raw = load_data(DATA_PATH) + print(f" Loaded {len(df_raw)} rows ({df_raw['date'].min()} to {df_raw['date'].max()})") + + print("Building features with 252-day forward target...") + df = build_prediction_features(df_raw) + print(f" Features built, {len(df)} rows with valid forward returns") + + if len(df) < 100: + print(f"WARNING: Only {len(df)} training samples. Need more historical data for reliable predictions.") + + print("Splitting data...") + train, val, test = split_data(df) + print(f" Train: {len(train)}, Val: {len(val)}, Test: {len(test)}") + + print("Standardizing features...") + feature_cols = get_feature_cols() + X_train, X_val, X_test, y_train, y_val, y_test, mean, std = standardize( + train, val, test, feature_cols + ) + print(f" {len(feature_cols)} features: {feature_cols[:3]}...") + + print("Training model...") + model = train_model(X_train, y_train, X_val, y_val) + + print("Evaluating on test set...") + device = next(model.parameters()).device + X_test_t = torch.tensor(X_test, dtype=torch.float32, device=device) + model.eval() + with torch.no_grad(): + test_predictions = model(X_test_t).cpu().numpy() + + test_metrics = compute_test_metrics(y_test, test_predictions) + print(f" Test R²: {test_metrics['r2']:.3f}, MSE: {test_metrics['mse']:.6f}") + + print("Estimating prediction uncertainty...") + pred_std = estimate_uncertainty(model, X_test, y_test) + print(f" Prediction std: {pred_std:.4f} ({pred_std*100:.1f}%)") + + print("Getting latest features for forward prediction...") + latest_features, latest_date = get_latest_features(df_raw, feature_cols) + print(f" Latest date: {latest_date}") + + # Standardize latest features using training statistics + latest_features_std = (latest_features - mean) / std + latest_features_t = torch.tensor(latest_features_std, dtype=torch.float32, device=device) + + print("Generating 12-month forward prediction...") + model.eval() + with torch.no_grad(): + prediction = model(latest_features_t).item() + + # Build result + result = { + "prediction_date": latest_date, + "horizon_days": FORWARD_DAYS, + "predicted_return_pct": round(prediction * 100, 2), + "confidence_interval_95": { + "low": round((prediction - 1.96 * pred_std) * 100, 2), + "high": round((prediction + 1.96 * pred_std) * 100, 2), + }, + "model_info": { + "features": feature_cols, + "train_samples": len(train), + "val_samples": len(val), + "test_samples": len(test), + "test_mse": round(test_metrics["mse"], 6), + "test_r2": round(test_metrics["r2"], 3), + }, + "caveats": [ + "Prediction based on historical patterns only", + "Confidence interval estimated from test set errors", + "Past performance does not guarantee future results", + "This is not financial advice", + ], + "generated_at": datetime.now().isoformat(), + } + + # Write to output file + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(OUTPUT_PATH, "w") as f: + json.dump(result, f, indent=2) + print(f"\nPrediction saved to: {OUTPUT_PATH}") + + # Print human-readable summary + print_prediction(result) + + +if __name__ == "__main__": + main() diff --git a/src/cli/etf-backtest/scripts/run_experiment.py b/src/cli/etf-backtest/scripts/run_experiment.py new file mode 100644 index 0000000..acca7d1 --- /dev/null +++ b/src/cli/etf-backtest/scripts/run_experiment.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Single Experiment Runner for ETF Feature Selection + +Combines backtest and prediction into one script. +Accepts input via stdin JSON, outputs results as JSON to stdout. + +Input format: +{ + "ticker": "SPY", + "featureIds": ["mom_1m", "mom_3m", "vol_1m", "px_sma50"], + "seed": 42 +} + +Output format: +{ + "featureIds": [...], + "metrics": { "sharpe": ..., "maxDrawdown": ..., "r2": ..., "mse": ..., "cagr": ... }, + "prediction": { "pred12mReturn": ..., "ci95Low": ..., "ci95High": ... } +} +""" + +import json +import sys +import numpy as np +import pandas as pd +import torch +from pathlib import Path + +from shared import ( + load_data, + build_selected_features, + add_forward_target, + split_data, + train_model, + FORWARD_DAYS, + ALL_FEATURE_IDS, +) + +# === CONFIG === +DATA_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "data.json" +COST_BPS = 5 # transaction cost in basis points + + +def set_seed(seed: int): + """Set random seeds for reproducibility.""" + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def standardize( + train: pd.DataFrame, + val: pd.DataFrame, + test: pd.DataFrame, + feature_cols: list[str], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Standardize features using train mean/std only.""" + X_train = train[feature_cols].values + X_val = val[feature_cols].values + X_test = test[feature_cols].values + + y_train = train["target"].values + y_val = val["target"].values + y_test = test["target"].values + + mean = X_train.mean(axis=0) + std = X_train.std(axis=0) + std[std == 0] = 1 + + X_train = (X_train - mean) / std + X_val = (X_val - mean) / std + X_test = (X_test - mean) / std + + return X_train, X_val, X_test, y_train, y_val, y_test, mean, std + + +def run_backtest(test_df: pd.DataFrame, predictions: np.ndarray) -> dict: + """Run backtest and compute metrics.""" + df = test_df.copy() + df["pred"] = predictions + + # Signal: pred > 0 -> long + df["signal"] = (df["pred"] > 0).astype(int) + + # Position with 1-day lag (avoids lookahead) + df["position"] = df["signal"].shift(1).fillna(0) + + # Need daily returns for backtest + df["daily_ret"] = df["price"].pct_change() + + # Strategy returns + df["strat_ret"] = df["position"] * df["daily_ret"] + + # Transaction costs + df["trade"] = df["position"].diff().abs().fillna(0) + df["cost"] = df["trade"] * (COST_BPS / 10000) + df["strat_ret_net"] = df["strat_ret"] - df["cost"] + + # Equity curve + df["equity"] = (1 + df["strat_ret_net"]).cumprod() + + # Metrics + returns = df["strat_ret_net"].dropna().values + equity = df["equity"].dropna().values + + if len(equity) < 2: + return {"sharpe": 0, "maxDrawdown": 0, "cagr": 0, "totalReturn": 0} + + total_return = equity[-1] / equity[0] - 1 + n_days = len(returns) + years = n_days / 252 + cagr = (equity[-1] ** (1 / years)) - 1 if years > 0 else 0 + + ann_vol = returns.std() * np.sqrt(252) + sharpe = (returns.mean() * 252) / ann_vol if ann_vol > 0 else 0 + + peak = np.maximum.accumulate(equity) + drawdown = (equity - peak) / peak + max_dd = drawdown.min() + + return { + "sharpe": float(sharpe), + "maxDrawdown": float(max_dd), + "cagr": float(cagr), + "totalReturn": float(total_return), + } + + +def compute_prediction( + model, + df_raw: pd.DataFrame, + feature_ids: list[str], + mean: np.ndarray, + std: np.ndarray, + uncertainty: dict, +) -> dict: + """Generate 12-month forward prediction with adjusted confidence interval.""" + device = next(model.parameters()).device + + # Build features for latest data point + df = build_selected_features(df_raw, feature_ids) + df = df.dropna(subset=feature_ids) + + if len(df) == 0: + raise ValueError("No valid feature rows for prediction") + + latest = df.iloc[-1] + features = latest[feature_ids].values.astype(np.float64) + + # Standardize using training statistics + features_std = (features - mean) / std + features_t = torch.tensor(features_std, dtype=torch.float32, device=device) + + model.eval() + with torch.no_grad(): + prediction = model(features_t).item() + + # Use adjusted std for more realistic confidence intervals + adjusted_std = uncertainty["adjustedStd"] + + return { + "pred12mReturn": float(prediction), + "ci95Low": float(prediction - 1.96 * adjusted_std), + "ci95High": float(prediction + 1.96 * adjusted_std), + "uncertainty": uncertainty, + } + + +def compute_test_metrics(y_test: np.ndarray, predictions: np.ndarray) -> dict: + """Compute R² and MSE on test set.""" + mse = float(np.mean((y_test - predictions) ** 2)) + ss_res = np.sum((y_test - predictions) ** 2) + ss_tot = np.sum((y_test - y_test.mean()) ** 2) + r2 = float(1 - ss_res / ss_tot) if ss_tot > 0 else 0.0 + return {"r2": r2, "mse": mse} + + +def compute_prediction_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict: + """Metrics focused on 12-month prediction quality.""" + direction_accuracy = float(np.mean((y_true > 0) == (y_pred > 0))) + mae = float(np.mean(np.abs(y_true - y_pred))) + pred_std = y_pred.std() + true_std = y_true.std() + calibration_ratio = float(pred_std / true_std) if true_std > 0 else 0.0 + return { + "directionAccuracy": direction_accuracy, + "mae": mae, + "calibrationRatio": calibration_ratio, + } + + +def compute_non_overlapping_metrics( + y_true: np.ndarray, y_pred: np.ndarray, forward_days: int = FORWARD_DAYS +) -> dict: + """Evaluate on non-overlapping windows for honest assessment.""" + n = len(y_true) + indices = list(range(0, n, forward_days)) + if len(indices) < 2: + return { + "r2NonOverlapping": 0.0, + "directionAccuracyNonOverlapping": 0.0, + "nonOverlappingSamples": len(indices), + } + + y_true_no = y_true[indices] + y_pred_no = y_pred[indices] + + # R² on non-overlapping samples + ss_res = np.sum((y_true_no - y_pred_no) ** 2) + ss_tot = np.sum((y_true_no - y_true_no.mean()) ** 2) + r2_no = float(1 - ss_res / ss_tot) if ss_tot > 0 else 0.0 + + # Direction accuracy on non-overlapping samples + dir_acc_no = float(np.mean((y_true_no > 0) == (y_pred_no > 0))) + + return { + "r2NonOverlapping": r2_no, + "directionAccuracyNonOverlapping": dir_acc_no, + "nonOverlappingSamples": len(indices), + } + + +def compute_uncertainty_adjusted( + test_preds: np.ndarray, + y_test: np.ndarray, + latest_features: np.ndarray, + train_mean: np.ndarray, + train_std: np.ndarray, +) -> dict: + """Uncertainty with extrapolation penalty and market floor.""" + residuals = y_test - test_preds + base_std = float(residuals.std()) + + # Extrapolation penalty if features are outside training distribution + z_scores = np.abs((latest_features - train_mean) / train_std) + max_z = float(z_scores.max()) + extrapolation_mult = 1.0 + 0.1 * max(0, max_z - 2) + + # Market floor: 12-month returns are inherently uncertain (~10% minimum) + MARKET_FLOOR = 0.10 + adjusted_std = max(base_std * extrapolation_mult, MARKET_FLOOR) + + return { + "baseStd": base_std, + "adjustedStd": float(adjusted_std), + "extrapolationMultiplier": float(extrapolation_mult), + "isExtrapolating": bool(max_z > 2), + } + + +def run_experiment(ticker: str, feature_ids: list[str], seed: int) -> dict: + """Run a single experiment with given features.""" + set_seed(seed) + + # Load data + if not DATA_PATH.exists(): + raise FileNotFoundError(f"Data file not found: {DATA_PATH}") + + df_raw = load_data(DATA_PATH) + + # Build features and add forward target + df = build_selected_features(df_raw, feature_ids) + df = add_forward_target(df, FORWARD_DAYS) + + # Drop NaN rows + df = df.dropna(subset=feature_ids + ["target"]).reset_index(drop=True) + + if len(df) < 100: + raise ValueError(f"Insufficient data: only {len(df)} valid rows") + + # Split data + train, val, test = split_data(df) + + # Standardize + X_train, X_val, X_test, y_train, y_val, y_test, mean, std = standardize( + train, val, test, feature_ids + ) + + # Train model + model = train_model(X_train, y_train, X_val, y_val) + + # Get test predictions + device = next(model.parameters()).device + X_test_t = torch.tensor(X_test, dtype=torch.float32, device=device) + model.eval() + with torch.no_grad(): + test_preds = model(X_test_t).cpu().numpy() + + # Compute model metrics (overlapping) + model_metrics = compute_test_metrics(y_test, test_preds) + prediction_metrics = compute_prediction_metrics(y_test, test_preds) + + # Compute non-overlapping metrics for honest assessment + non_overlap_metrics = compute_non_overlapping_metrics(y_test, test_preds, FORWARD_DAYS) + + # Build features for latest data point to compute uncertainty + df_latest = build_selected_features(df_raw, feature_ids) + df_latest = df_latest.dropna(subset=feature_ids) + latest_features = df_latest.iloc[-1][feature_ids].values.astype(np.float64) + + # Compute adjusted uncertainty + uncertainty = compute_uncertainty_adjusted(test_preds, y_test, latest_features, mean, std) + + # Run backtest on test set (informational only) + backtest_metrics = run_backtest(test, test_preds) + + # Generate forward prediction with adjusted uncertainty + prediction = compute_prediction(model, df_raw, feature_ids, mean, std, uncertainty) + + return { + "featureIds": feature_ids, + "metrics": { + # Backtest metrics (informational, not optimization target) + "sharpe": backtest_metrics["sharpe"], + "maxDrawdown": backtest_metrics["maxDrawdown"], + "cagr": backtest_metrics["cagr"], + # Prediction metrics (overlapping) + "r2": model_metrics["r2"], + "mse": model_metrics["mse"], + "directionAccuracy": prediction_metrics["directionAccuracy"], + "mae": prediction_metrics["mae"], + "calibrationRatio": prediction_metrics["calibrationRatio"], + # Non-overlapping metrics (honest assessment) + "r2NonOverlapping": non_overlap_metrics["r2NonOverlapping"], + "directionAccuracyNonOverlapping": non_overlap_metrics["directionAccuracyNonOverlapping"], + }, + "prediction": prediction, + "modelInfo": { + "trainSamples": len(train), + "valSamples": len(val), + "testSamples": len(test), + }, + "dataInfo": { + "totalSamples": len(df), + "nonOverlappingSamples": non_overlap_metrics["nonOverlappingSamples"], + "effectiveIndependentPeriods": non_overlap_metrics["nonOverlappingSamples"], + }, + } + + +def main(): + # Read input from stdin + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(json.dumps({"error": f"Invalid JSON input: {e}"}), file=sys.stdout) + sys.exit(1) + + ticker = input_data.get("ticker", "SPY") + feature_ids = input_data.get("featureIds") + if feature_ids is None: + feature_ids = input_data.get("feature_ids", []) + seed = input_data.get("seed", 42) + + # Validate featureIds + if not feature_ids: + print(json.dumps({"error": "featureIds is required and must not be empty"}), file=sys.stdout) + sys.exit(1) + + invalid = [f for f in feature_ids if f not in ALL_FEATURE_IDS] + if invalid: + print(json.dumps({ + "error": f"Unknown featureIds: {invalid}", + "validFeatures": ALL_FEATURE_IDS, + }), file=sys.stdout) + sys.exit(1) + + try: + result = run_experiment(ticker, feature_ids, seed) + print(json.dumps(result, indent=2), file=sys.stdout) + except Exception as e: + print(json.dumps({"error": str(e)}), file=sys.stdout) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/cli/etf-backtest/scripts/shared.py b/src/cli/etf-backtest/scripts/shared.py new file mode 100644 index 0000000..203e10b --- /dev/null +++ b/src/cli/etf-backtest/scripts/shared.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +""" +Shared utilities for ETF backtest and prediction scripts. + +Contains common code for data loading, feature engineering, and model training. +""" + +import json +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +from pathlib import Path +from typing import Callable + +# === CONFIG === +TRAIN_RATIO = 0.70 +VAL_RATIO = 0.15 +# TEST_RATIO = 0.15 (implicit) + +LAGS = 20 # number of lagged returns +MA_SHORT = 10 # short moving average window +MA_LONG = 50 # long moving average window +VOL_WINDOW = 20 # rolling volatility window + +HIDDEN1 = 64 +HIDDEN2 = 32 +DROPOUT = 0.2 +LR = 0.001 +EPOCHS = 100 +PATIENCE = 10 # early stopping patience +BATCH_SIZE = 32 + +FORWARD_DAYS = 252 # ~12 months for prediction target + + +# === DATA LOADING === +def load_data(path: Path) -> pd.DataFrame: + """Load JSON and convert to DataFrame with date and price.""" + with open(path) as f: + data = json.load(f) + + series = data["series"] + df = pd.DataFrame([ + {"date": item["date"], "cumret": item["value"]["raw"]} + for item in series + ]) + df["date"] = pd.to_datetime(df["date"]) + df = df.sort_values("date").reset_index(drop=True) + + # Convert cumulative % return to price (base=100) + df["price"] = 100 * (1 + df["cumret"] / 100) + return df + + +# === FEATURE ENGINEERING === +def build_base_features(df: pd.DataFrame) -> pd.DataFrame: + """ + Build base features from price series. + Does NOT set target - caller must add their own target column. + """ + df = df.copy() + + # Daily returns + df["ret"] = df["price"].pct_change() + + # Lagged returns: r(t-1), r(t-2), ..., r(t-LAGS) + for i in range(1, LAGS + 1): + df[f"ret_lag{i}"] = df["ret"].shift(i) + + # Moving average ratios + df["ma_short"] = df["price"].rolling(MA_SHORT).mean() + df["ma_long"] = df["price"].rolling(MA_LONG).mean() + df["ma_ratio_short"] = df["price"] / df["ma_short"] - 1 + df["ma_ratio_long"] = df["price"] / df["ma_long"] - 1 + + # Rolling volatility + df["volatility"] = df["ret"].rolling(VOL_WINDOW).std() + + return df + + +def get_feature_cols() -> list[str]: + """Return list of feature column names.""" + cols = [f"ret_lag{i}" for i in range(1, LAGS + 1)] + cols += ["ma_ratio_short", "ma_ratio_long", "volatility"] + return cols + + +# === TRAIN/VAL/TEST SPLIT === +def split_data(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: + """Chronological split into train/val/test.""" + n = len(df) + train_end = int(n * TRAIN_RATIO) + val_end = int(n * (TRAIN_RATIO + VAL_RATIO)) + + train = df.iloc[:train_end].copy() + val = df.iloc[train_end:val_end].copy() + test = df.iloc[val_end:].copy() + + return train, val, test + + +def standardize(train: pd.DataFrame, val: pd.DataFrame, test: pd.DataFrame, + feature_cols: list[str]) -> tuple[np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray]: + """ + Standardize features using train mean/std only. + Returns X_train, X_val, X_test, y_train, y_val, y_test, mean, std. + """ + X_train = train[feature_cols].values + X_val = val[feature_cols].values + X_test = test[feature_cols].values + + y_train = train["target"].values + y_val = val["target"].values + y_test = test["target"].values + + # Compute mean/std from train only + mean = X_train.mean(axis=0) + std = X_train.std(axis=0) + std[std == 0] = 1 # avoid division by zero + + X_train = (X_train - mean) / std + X_val = (X_val - mean) / std + X_test = (X_test - mean) / std + + return X_train, X_val, X_test, y_train, y_val, y_test, mean, std + + +# === MODEL === +class MLP(nn.Module): + """Simple MLP: Input -> 64 -> 32 -> 1""" + def __init__(self, input_dim: int): + super().__init__() + self.net = nn.Sequential( + nn.Linear(input_dim, HIDDEN1), + nn.ReLU(), + nn.Dropout(DROPOUT), + nn.Linear(HIDDEN1, HIDDEN2), + nn.ReLU(), + nn.Dropout(DROPOUT), + nn.Linear(HIDDEN2, 1) + ) + + def forward(self, x): + return self.net(x).squeeze(-1) + + +def train_model(X_train: np.ndarray, y_train: np.ndarray, + X_val: np.ndarray, y_val: np.ndarray) -> MLP: + """Train MLP with early stopping.""" + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + X_train_t = torch.tensor(X_train, dtype=torch.float32, device=device) + y_train_t = torch.tensor(y_train, dtype=torch.float32, device=device) + X_val_t = torch.tensor(X_val, dtype=torch.float32, device=device) + y_val_t = torch.tensor(y_val, dtype=torch.float32, device=device) + + model = MLP(X_train.shape[1]).to(device) + optimizer = torch.optim.Adam(model.parameters(), lr=LR) + criterion = nn.MSELoss() + + best_val_loss = float("inf") + patience_counter = 0 + best_state = None + + n_batches = (len(X_train_t) + BATCH_SIZE - 1) // BATCH_SIZE + + for epoch in range(EPOCHS): + model.train() + indices = torch.randperm(len(X_train_t)) + + for i in range(n_batches): + batch_idx = indices[i*BATCH_SIZE : (i+1)*BATCH_SIZE] + X_batch = X_train_t[batch_idx] + y_batch = y_train_t[batch_idx] + + optimizer.zero_grad() + pred = model(X_batch) + loss = criterion(pred, y_batch) + loss.backward() + optimizer.step() + + # Validation + model.eval() + with torch.no_grad(): + val_pred = model(X_val_t) + val_loss = criterion(val_pred, y_val_t).item() + + if val_loss < best_val_loss: + best_val_loss = val_loss + patience_counter = 0 + best_state = model.state_dict() + else: + patience_counter += 1 + if patience_counter >= PATIENCE: + print(f"Early stopping at epoch {epoch+1}") + break + + if best_state: + model.load_state_dict(best_state) + + return model + + +# === FEATURE REGISTRY === +def compute_rsi(prices: pd.Series, period: int = 14) -> pd.Series: + """Compute Relative Strength Index.""" + delta = prices.diff() + gain = delta.where(delta > 0, 0.0).rolling(period).mean() + loss = (-delta.where(delta < 0, 0.0)).rolling(period).mean() + rs = gain / loss.replace(0, np.nan) + return 100 - (100 / (1 + rs)) + + +def compute_bb_width(prices: pd.Series, period: int = 20, num_std: float = 2) -> pd.Series: + """Compute Bollinger Band width (normalized by middle band).""" + sma = prices.rolling(period).mean() + std = prices.rolling(period).std() + upper = sma + num_std * std + lower = sma - num_std * std + return (upper - lower) / sma + + +def compute_rolling_mdd(df: pd.DataFrame, window: int) -> pd.Series: + """Compute rolling maximum drawdown over a window.""" + def mdd_func(x): + peak = np.maximum.accumulate(x) + dd = (x - peak) / peak + return dd.min() + return df["price"].rolling(window).apply(mdd_func, raw=True) + + +# Feature registry: maps feature_id to a function that computes the feature +FEATURE_REGISTRY: dict[str, Callable[[pd.DataFrame], pd.Series]] = { + # Momentum (returns over periods) + "mom_1m": lambda df: df["price"].pct_change(21), + "mom_3m": lambda df: df["price"].pct_change(63), + "mom_6m": lambda df: df["price"].pct_change(126), + "mom_12m": lambda df: df["price"].pct_change(252), + + # Trend (price vs moving averages) + "px_sma50": lambda df: df["price"] / df["price"].rolling(50).mean() - 1, + "px_sma200": lambda df: df["price"] / df["price"].rolling(200).mean() - 1, + "sma50_sma200": lambda df: df["price"].rolling(50).mean() / df["price"].rolling(200).mean() - 1, + "dist_52w_high": lambda df: df["price"] / df["price"].rolling(252).max() - 1, + + # Risk (volatility and drawdown) + "vol_1m": lambda df: df["price"].pct_change().rolling(21).std(), + "vol_3m": lambda df: df["price"].pct_change().rolling(63).std(), + "vol_6m": lambda df: df["price"].pct_change().rolling(126).std(), + "dd_current": lambda df: df["price"] / df["price"].cummax() - 1, + "mdd_12m": lambda df: compute_rolling_mdd(df, 252), + + # Oscillators + "rsi_14": lambda df: compute_rsi(df["price"], 14), + "bb_width": lambda df: compute_bb_width(df["price"], 20, 2), +} + +ALL_FEATURE_IDS = list(FEATURE_REGISTRY.keys()) + + +def build_selected_features(df: pd.DataFrame, feature_ids: list[str]) -> pd.DataFrame: + """ + Build only the selected features from the registry. + Returns DataFrame with price, date, and selected feature columns. + """ + df = df.copy() + + # Validate feature_ids + invalid = [f for f in feature_ids if f not in FEATURE_REGISTRY] + if invalid: + raise ValueError(f"Unknown feature_ids: {invalid}") + + # Compute each selected feature + for feature_id in feature_ids: + df[feature_id] = FEATURE_REGISTRY[feature_id](df) + + return df + + +def add_forward_target(df: pd.DataFrame, forward_days: int = FORWARD_DAYS) -> pd.DataFrame: + """Add forward return target for prediction.""" + df = df.copy() + df["target"] = df["price"].shift(-forward_days) / df["price"] - 1 + return df diff --git a/src/tools/run-python/run-python-tool.test.ts b/src/tools/run-python/run-python-tool.test.ts new file mode 100644 index 0000000..961d867 --- /dev/null +++ b/src/tools/run-python/run-python-tool.test.ts @@ -0,0 +1,245 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { TMP_ROOT } from "~tools/utils/fs"; +import { invokeTool } from "~tools/utils/test-utils"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { PythonResult } from "./run-python-tool"; +import { createRunPythonTool, isValidScriptName } from "./run-python-tool"; + +describe("isValidScriptName", () => { + it("accepts valid script names", () => { + expect(isValidScriptName("hello.py")).toBe(true); + expect(isValidScriptName("my_script.py")).toBe(true); + expect(isValidScriptName("test-script.py")).toBe(true); + expect(isValidScriptName("Script123.py")).toBe(true); + }); + + it("rejects non-.py extensions", () => { + expect(isValidScriptName("hello.js")).toBe(false); + expect(isValidScriptName("hello.txt")).toBe(false); + expect(isValidScriptName("hello")).toBe(false); + expect(isValidScriptName("hello.py.txt")).toBe(false); + }); + + it("rejects path separators", () => { + expect(isValidScriptName("subdir/hello.py")).toBe(false); + expect(isValidScriptName("../hello.py")).toBe(false); + expect(isValidScriptName("subdir\\hello.py")).toBe(false); + }); + + it("rejects path traversal", () => { + expect(isValidScriptName("..hello.py")).toBe(false); + expect(isValidScriptName("hello..py")).toBe(false); + }); + + it("rejects special characters", () => { + expect(isValidScriptName("hello world.py")).toBe(false); + expect(isValidScriptName("hello@script.py")).toBe(false); + expect(isValidScriptName("hello$script.py")).toBe(false); + }); +}); + +describe("createRunPythonTool", () => { + let testDir = ""; + let scriptsDir = ""; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const mockLogger = { tool: () => {} } as never; + + beforeEach(async () => { + await fs.mkdir(TMP_ROOT, { recursive: true }); + testDir = await fs.mkdtemp(path.join(TMP_ROOT, "vitest-python-")); + scriptsDir = path.join(testDir, "scripts"); + await fs.mkdir(scriptsDir, { recursive: true }); + }); + + afterEach(async () => { + if (testDir) { + await fs.rm(testDir, { recursive: true, force: true }); + } + testDir = ""; + scriptsDir = ""; + }); + + it("executes a valid Python script", async () => { + const scriptContent = 'print("Hello from Python")'; + await fs.writeFile( + path.join(scriptsDir, "hello.py"), + scriptContent, + "utf8" + ); + + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "hello.py", + input: "", + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Hello from Python"); + expect(result.stderr).toBe(""); + }); + + it("captures stderr from Python script", async () => { + const scriptContent = ` +import sys +sys.stderr.write("Error message") +sys.exit(1) +`; + await fs.writeFile( + path.join(scriptsDir, "error.py"), + scriptContent, + "utf8" + ); + + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "error.py", + input: "", + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(false); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Error message"); + }); + + it("rejects invalid script names", async () => { + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "../etc/passwd", + input: "", + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(false); + expect(result.error).toContain("Invalid script name"); + }); + + it("handles non-existent scripts", async () => { + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "nonexistent.py", + input: "", + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(false); + // Python exits with non-zero code when script doesn't exist + expect(result.exitCode).not.toBe(0); + }); + + it("calls logger when provided", async () => { + const scriptContent = 'print("test")'; + await fs.writeFile(path.join(scriptsDir, "test.py"), scriptContent, "utf8"); + + const loggedMessages: string[] = []; + const mockLogger = { + tool: (msg: string) => loggedMessages.push(msg), + }; + + const tool = createRunPythonTool({ + scriptsDir, + logger: mockLogger as never, + }); + await invokeTool(tool, { scriptName: "test.py", input: "" }); + + expect(loggedMessages.length).toBe(2); + expect(loggedMessages[0]).toContain("Running Python script"); + expect(loggedMessages[1]).toContain("Python result"); + }); + + it("passes JSON input via stdin", async () => { + const scriptContent = ` +import json +import sys +data = json.load(sys.stdin) +print(json.dumps({"received": data})) +`; + await fs.writeFile( + path.join(scriptsDir, "stdin_test.py"), + scriptContent, + "utf8" + ); + + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "stdin_test.py", + input: '{"message":"hello","count":42}', + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + + const output = JSON.parse(result.stdout.trim()) as { + received: { message: string; count: number }; + }; + expect(output.received.message).toBe("hello"); + expect(output.received.count).toBe(42); + }); + + it("works with empty input string", async () => { + const scriptContent = 'print("no stdin needed")'; + await fs.writeFile( + path.join(scriptsDir, "no_stdin.py"), + scriptContent, + "utf8" + ); + + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "no_stdin.py", + input: "", + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(true); + expect(result.stdout).toContain("no stdin needed"); + }); + + it("handles invalid JSON input", async () => { + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "any.py", + input: "not valid json", + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(false); + expect(result.error).toBe("Invalid JSON in input parameter"); + }); + + it("handles complex nested input objects", async () => { + const scriptContent = ` +import json +import sys +data = json.load(sys.stdin) +print(json.dumps({"features": data["feature_ids"], "seed": data["seed"]})) +`; + await fs.writeFile( + path.join(scriptsDir, "nested_input.py"), + scriptContent, + "utf8" + ); + + const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); + const resultJson = await invokeTool(tool, { + scriptName: "nested_input.py", + input: + '{"ticker":"SPY","feature_ids":["mom_1m","vol_3m","px_sma50"],"seed":42}', + }); + const result = JSON.parse(resultJson) as PythonResult; + + expect(result.success).toBe(true); + + const output = JSON.parse(result.stdout.trim()) as { + features: string[]; + seed: number; + }; + expect(output.features).toEqual(["mom_1m", "vol_3m", "px_sma50"]); + expect(output.seed).toBe(42); + }); +}); diff --git a/src/tools/run-python/run-python-tool.ts b/src/tools/run-python/run-python-tool.ts new file mode 100644 index 0000000..9f2512f --- /dev/null +++ b/src/tools/run-python/run-python-tool.ts @@ -0,0 +1,265 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import { tool } from "@openai/agents"; +import type { Logger } from "~clients/logger"; + +/** + * Result of a Python script execution + */ +export type PythonResult = { + success: boolean; + exitCode: number | null; + stdout: string; + stderr: string; + durationMs: number; + error?: string; +}; + +/** + * Default configuration values + */ +const DEFAULTS = { + timeoutMs: 30000, + maxOutputBytes: 50 * 1024, // 50KB + pythonBinary: "python3", +} as const; + +/** + * Maximum allowed values + */ +const MAX_VALUES = { + timeoutMs: 120000, +} as const; + +/** + * Clamp a value between bounds + */ +const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +/** + * Validate that script name is safe (no path traversal) + */ +export const isValidScriptName = (scriptName: string): boolean => { + // Must end with .py + if (!scriptName.endsWith(".py")) { + return false; + } + + // No path separators allowed (no subdirectories) + if (scriptName.includes("/") || scriptName.includes("\\")) { + return false; + } + + // No path traversal + if (scriptName.includes("..")) { + return false; + } + + // Only allow alphanumeric, underscores, hyphens, and .py extension + const validPattern = /^[a-zA-Z0-9_-]+\.py$/; + return validPattern.test(scriptName); +}; + +/** + * Execute a Python script from the specified scripts directory + */ +const executePython = async (params: { + scriptsDir: string; + scriptName: string; + args?: string[]; + input?: Record; + timeoutMs?: number; + pythonBinary?: string; +}): Promise => { + const { + scriptsDir, + scriptName, + args = [], + input, + timeoutMs = DEFAULTS.timeoutMs, + pythonBinary = DEFAULTS.pythonBinary, + } = params; + + const startTime = Date.now(); + + // Validate script name + if (!isValidScriptName(scriptName)) { + return { + success: false, + exitCode: null, + stdout: "", + stderr: "", + durationMs: Date.now() - startTime, + error: `Invalid script name: "${scriptName}". Must be a .py file with no path separators.`, + }; + } + + const scriptPath = path.join(scriptsDir, scriptName); + const effectiveTimeout = clamp(timeoutMs, 1000, MAX_VALUES.timeoutMs); + + return new Promise((resolve) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, effectiveTimeout); + + let stdout = ""; + let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; + + const proc = spawn(pythonBinary, [scriptPath, ...args], { + signal: controller.signal, + cwd: scriptsDir, + }); + + // Write JSON input to stdin if provided + if (input !== undefined) { + proc.stdin.write(JSON.stringify(input)); + proc.stdin.end(); + } else { + proc.stdin.end(); + } + + proc.stdout.on("data", (data: Buffer) => { + if (stdout.length < DEFAULTS.maxOutputBytes) { + stdout += data.toString(); + if (stdout.length > DEFAULTS.maxOutputBytes) { + stdout = stdout.slice(0, DEFAULTS.maxOutputBytes); + stdoutTruncated = true; + } + } + }); + + proc.stderr.on("data", (data: Buffer) => { + if (stderr.length < DEFAULTS.maxOutputBytes) { + stderr += data.toString(); + if (stderr.length > DEFAULTS.maxOutputBytes) { + stderr = stderr.slice(0, DEFAULTS.maxOutputBytes); + stderrTruncated = true; + } + } + }); + + proc.on("close", (code) => { + clearTimeout(timeoutId); + const durationMs = Date.now() - startTime; + + if (stdoutTruncated) { + stdout += "\n[OUTPUT TRUNCATED]"; + } + if (stderrTruncated) { + stderr += "\n[OUTPUT TRUNCATED]"; + } + + resolve({ + success: code === 0, + exitCode: code, + stdout, + stderr, + durationMs, + }); + }); + + proc.on("error", (err) => { + clearTimeout(timeoutId); + const durationMs = Date.now() - startTime; + + if (err.name === "AbortError") { + resolve({ + success: false, + exitCode: null, + stdout, + stderr, + durationMs, + error: `Script execution timed out after ${effectiveTimeout}ms`, + }); + } else { + resolve({ + success: false, + exitCode: null, + stdout, + stderr, + durationMs, + error: err.message, + }); + } + }); + }); +}; + +export type RunPythonToolOptions = { + /** Absolute path to the directory containing Python scripts */ + scriptsDir: string; + /** Logger for tool execution logging */ + logger: Logger; + /** Python binary to use (defaults to "python3") */ + pythonBinary?: string; +}; + +/** + * Creates a tool to execute Python scripts from a specified directory. + * Scripts must be pre-defined .py files in the configured scriptsDir. + */ +export const createRunPythonTool = ({ + scriptsDir, + logger, + pythonBinary, +}: RunPythonToolOptions) => + tool({ + name: "runPython", + description: + "Executes a Python script from the configured scripts directory. " + + "Only .py files in the scripts directory can be executed. " + + "Optionally accepts JSON input to pass via stdin. " + + "Returns stdout, stderr, exit code, and execution time.", + parameters: { + type: "object", + properties: { + scriptName: { + type: "string", + description: + 'Name of the Python script to run (e.g., "hello.py"). Must be a .py file in the scripts directory.', + }, + input: { + type: "string", + description: + 'JSON string to pass to the script via stdin. Pass empty string "" if no input needed. The script should read from stdin using json.load(sys.stdin).', + }, + }, + required: ["scriptName", "input"], + additionalProperties: false, + }, + execute: async (params: { scriptName: string; input: string }) => { + logger.tool(`Running Python script: ${params.scriptName}`); + + // Parse the input string to object if provided (empty string means no input) + let parsedInput: Record | undefined; + if (params.input && params.input.trim() !== "") { + try { + parsedInput = JSON.parse(params.input) as Record; + } catch { + return JSON.stringify({ + success: false, + exitCode: null, + stdout: "", + stderr: "", + durationMs: 0, + error: "Invalid JSON in input parameter", + } satisfies PythonResult); + } + } + + const result = await executePython({ + scriptsDir, + scriptName: params.scriptName, + input: parsedInput, + pythonBinary, + }); + logger.tool( + `Python result: success=${result.success}, exitCode=${String(result.exitCode)}, durationMs=${result.durationMs}${result.error ? `, error=${result.error}` : ""}` + ); + return JSON.stringify(result, null, 2); + }, + }); From 6b57331500bdf031cf4b2f0424fc617fa419a15d Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:29:11 +0200 Subject: [PATCH 02/19] feat: implement agent runner with logging and event handling - Add AgentRunner class to manage agent execution and logging - Create utility functions for result formatting and extraction - Refactor existing code to utilize the new AgentRunner --- src/cli/etf-backtest/main.ts | 257 +----------- .../etf-backtest/utils/experiment-extract.ts | 69 ++++ src/cli/etf-backtest/utils/final-report.ts | 115 ++++++ src/cli/etf-backtest/utils/formatters.ts | 9 + src/cli/etf-backtest/utils/scoring.ts | 21 + src/cli/name-explorer/main.ts | 32 +- src/clients/agent-runner.test.ts | 388 ++++++++++++++++++ src/clients/agent-runner.ts | 111 +++++ 8 files changed, 732 insertions(+), 270 deletions(-) create mode 100644 src/cli/etf-backtest/utils/experiment-extract.ts create mode 100644 src/cli/etf-backtest/utils/final-report.ts create mode 100644 src/cli/etf-backtest/utils/formatters.ts create mode 100644 src/cli/etf-backtest/utils/scoring.ts create mode 100644 src/clients/agent-runner.test.ts create mode 100644 src/clients/agent-runner.ts diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 42ce9f9..a2c9f76 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -5,35 +5,26 @@ import "dotenv/config"; -import { Agent, MemorySession, Runner } from "@openai/agents"; +import { AgentRunner } from "~clients/agent-runner"; import { Logger } from "~clients/logger"; import { createRunPythonTool } from "~tools/run-python/run-python-tool"; import { parseArgs } from "~utils/parse-args"; import { AGENT_NAME, - CI_LEVEL_PERCENT, - CONFIDENCE_THRESHOLDS, DECIMAL_PLACES, FEATURE_MENU, - INDEX_NOT_FOUND, - JSON_SLICE_END_OFFSET, - LINE_SEPARATOR, MAX_FEATURES, MAX_NO_IMPROVEMENT, MAX_TURNS_PER_ITERATION, MIN_FEATURES, MODEL_NAME, - NEGATIVE_SHARPE_PENALTY, - NEGATIVE_SHARPE_THRESHOLD, NO_IMPROVEMENT_REASON, OVERLAP_PERCENT, - PERCENT_MULTIPLIER, PREDICTION_HORIZON_MONTHS, PYTHON_BINARY, REASONING_PREVIEW_LIMIT, SAMPLES_PER_DECADE, - SCORE_WEIGHTS, SCRIPTS_DIR, TARGET_CALIBRATION_MAX, TARGET_CALIBRATION_MIN, @@ -42,12 +33,12 @@ import { TOOL_RESULT_PREVIEW_LIMIT, ZERO, } from "./constants"; -import { - AgentOutputSchema, - CliArgsSchema, - ExperimentResultSchema, -} from "./schemas"; +import { AgentOutputSchema, CliArgsSchema } from "./schemas"; import type { ExperimentResult } from "./schemas"; +import { extractLastExperimentResult } from "./utils/experiment-extract"; +import { printFinalResults } from "./utils/final-report"; +import { formatFixed, formatPercent } from "./utils/formatters"; +import { computeScore } from "./utils/scoring"; const logger = new Logger(); @@ -57,14 +48,6 @@ const { verbose, ticker, maxIterations, seed } = parseArgs({ schema: CliArgsSchema, }); -const formatPercent = ( - value: number, - decimals = DECIMAL_PLACES.percent -): string => `${(value * PERCENT_MULTIPLIER).toFixed(decimals)}%`; - -const formatFixed = (value: number, decimals: number): string => - value.toFixed(decimals); - // --- Build agent instructions --- const buildInstructions = () => ` You are an ETF feature selection optimization agent. Your goal is to find features that produce **accurate ${PREDICTION_HORIZON_MONTHS}-month return predictions**, not optimal trading strategies. @@ -135,165 +118,23 @@ After each experiment, respond with JSON (do not call any more tools): } `; -// --- Compute improvement score --- -const computeScore = (metrics: ExperimentResult["metrics"]): number => { - // Primary: prediction accuracy on non-overlapping samples (honest assessment) - // Secondary: Sharpe < 0 is a red flag (sanity check only) - return ( - metrics.r2NonOverlapping * SCORE_WEIGHTS.r2NonOverlapping + - metrics.directionAccuracyNonOverlapping * - SCORE_WEIGHTS.directionAccuracyNonOverlapping + - metrics.mae * SCORE_WEIGHTS.mae + - (metrics.sharpe < NEGATIVE_SHARPE_THRESHOLD - ? NEGATIVE_SHARPE_PENALTY - : ZERO) - ); -}; - -// --- Print final results --- -const printFinalResults = ( - bestResult: ExperimentResult, - bestIteration: number, - totalIterations: number, - stopReason: string -) => { - // Confidence note based on non-overlapping metrics - const ciWidth = - bestResult.prediction.ci95High - bestResult.prediction.ci95Low; - let confidence = "LOW"; - if ( - bestResult.metrics.r2NonOverlapping > - CONFIDENCE_THRESHOLDS.moderate.r2NonOverlapping && - bestResult.metrics.directionAccuracyNonOverlapping > - CONFIDENCE_THRESHOLDS.moderate.directionAccuracyNonOverlapping && - ciWidth < CONFIDENCE_THRESHOLDS.moderate.maxCiWidth - ) { - confidence = "MODERATE"; - } - if ( - bestResult.metrics.r2NonOverlapping > - CONFIDENCE_THRESHOLDS.reasonable.r2NonOverlapping && - bestResult.metrics.directionAccuracyNonOverlapping > - CONFIDENCE_THRESHOLDS.reasonable.directionAccuracyNonOverlapping && - ciWidth < CONFIDENCE_THRESHOLDS.reasonable.maxCiWidth - ) { - confidence = "REASONABLE"; - } - - const lines = [ - "", - LINE_SEPARATOR, - "OPTIMIZATION COMPLETE", - LINE_SEPARATOR, - `Iterations: ${totalIterations}`, - `Best iteration: ${bestIteration}`, - `Stop reason: ${stopReason}`, - "", - "Best Feature Set:", - ...bestResult.featureIds.map((feature) => ` - ${feature}`), - "", - "Prediction Accuracy (Non-Overlapping - Honest Assessment):", - ` R²: ${formatFixed( - bestResult.metrics.r2NonOverlapping, - DECIMAL_PLACES.r2 - )}`, - ` Direction Accuracy: ${formatPercent( - bestResult.metrics.directionAccuracyNonOverlapping - )}`, - ` Independent Samples: ${bestResult.dataInfo.nonOverlappingSamples}`, - "", - "Prediction Accuracy (Overlapping - Inflated):", - ` R²: ${formatFixed( - bestResult.metrics.r2, - DECIMAL_PLACES.r2 - )}`, - ` Direction Accuracy: ${formatPercent( - bestResult.metrics.directionAccuracy - )}`, - ` MAE: ${formatPercent(bestResult.metrics.mae)}`, - ` Calibration: ${formatFixed( - bestResult.metrics.calibrationRatio, - DECIMAL_PLACES.calibration - )}`, - "", - "Backtest Metrics (Informational):", - ` Sharpe Ratio: ${formatFixed( - bestResult.metrics.sharpe, - DECIMAL_PLACES.sharpe - )}`, - ` Max Drawdown: ${formatPercent(bestResult.metrics.maxDrawdown)}`, - ` CAGR: ${formatPercent( - bestResult.metrics.cagr, - DECIMAL_PLACES.cagr - )}`, - "", - `${PREDICTION_HORIZON_MONTHS}-Month Prediction:`, - ` Expected Return: ${formatPercent(bestResult.prediction.pred12mReturn)}`, - ` ${CI_LEVEL_PERCENT}% CI: [${formatPercent( - bestResult.prediction.ci95Low - )}, ${formatPercent(bestResult.prediction.ci95High)}]`, - "", - "Uncertainty Details:", - ` Base Std: ${formatPercent( - bestResult.prediction.uncertainty.baseStd - )}`, - ` Adjusted Std: ${formatPercent( - bestResult.prediction.uncertainty.adjustedStd - )}`, - ` Extrapolation: ${ - bestResult.prediction.uncertainty.isExtrapolating - ? "Yes (features outside training range)" - : "No" - }`, - "", - `Confidence: ${confidence}`, - `Note: Non-overlapping metrics use only ${bestResult.dataInfo.nonOverlappingSamples} independent periods.`, - "Past performance does not guarantee future results.", - LINE_SEPARATOR, - ]; - - logger.info(lines.join("\n")); -}; - // --- Run iterative optimization --- -const runOptimization = async () => { +const runAgentOptimization = async () => { const runPythonTool = createRunPythonTool({ scriptsDir: SCRIPTS_DIR, logger, pythonBinary: PYTHON_BINARY, }); - const agent = new Agent({ + const agentRunner = new AgentRunner({ name: AGENT_NAME, model: MODEL_NAME, tools: [runPythonTool], outputType: AgentOutputSchema, instructions: buildInstructions(), - }); - - const runner = new Runner(); - const session = new MemorySession(); - - // Tool logging - const toolsInProgress = new Set(); - runner.on("agent_tool_start", (_context, _agent, tool, details) => { - const toolCall = details.toolCall as Record; - const callId = toolCall.id as string; - if (toolsInProgress.has(callId)) { - return; - } - toolsInProgress.add(callId); - logger.tool(`Calling ${tool.name}`); - }); - runner.on("agent_tool_end", (_context, _agent, tool, result) => { - logger.tool(`${tool.name} completed`); - if (verbose) { - const preview = - result.length > TOOL_RESULT_PREVIEW_LIMIT - ? result.substring(ZERO, TOOL_RESULT_PREVIEW_LIMIT) + "..." - : result; - logger.debug(`Result: ${preview}`); - } + logger, + logToolResults: verbose, + resultPreviewLimit: TOOL_RESULT_PREVIEW_LIMIT, }); // Track state @@ -324,8 +165,7 @@ After running the experiment, analyze the results and decide whether to continue let runResult; try { - runResult = await runner.run(agent, currentPrompt, { - session, + runResult = await agentRunner.run(currentPrompt, { maxTurns: MAX_TURNS_PER_ITERATION, // Limit turns per iteration: 1 tool call + 1 result + 1 output }); } catch (err) { @@ -446,87 +286,18 @@ Backtest metrics (Sharpe, drawdown) are informational only. // Output final results if (bestResult) { - printFinalResults(bestResult, bestIteration, iteration, stopReason); + printFinalResults(logger, bestResult, bestIteration, iteration, stopReason); } else { logger.warn("No successful experiments completed."); } }; -// Extract JSON object from stdout which may contain other output before/after -const extractJsonFromStdout = (stdout: string): unknown => { - // Find the first '{' and match to its closing '}' - const startIdx = stdout.indexOf("{"); - if (startIdx === INDEX_NOT_FOUND) { - return null; - } - - let braceCount = ZERO; - let endIdx = INDEX_NOT_FOUND; - for (let i = startIdx; i < stdout.length; i++) { - if (stdout[i] === "{") { - braceCount++; - } - if (stdout[i] === "}") { - braceCount--; - } - if (braceCount === ZERO) { - endIdx = i; - break; - } - } - - if (endIdx === INDEX_NOT_FOUND) { - return null; - } - - const jsonStr = stdout.slice(startIdx, endIdx + JSON_SLICE_END_OFFSET); - return JSON.parse(jsonStr); -}; - -// Helper to extract experiment result from runner result -const extractLastExperimentResult = (runResult: { - newItems?: { type: string; output?: unknown }[]; -}): ExperimentResult | null => { - try { - // Look through the newItems for tool call outputs - const items = runResult.newItems ?? []; - for (const item of items) { - if (item.type === "tool_call_output_item" && item.output) { - const output = item.output; - // Output may be a string (JSON) or already parsed object - let parsed: unknown; - if (typeof output === "string") { - parsed = JSON.parse(output); - } else { - parsed = output; - } - - // The Python tool returns { success, exitCode, stdout, stderr } - const toolResult = parsed as { stdout?: string }; - if (toolResult.stdout) { - // Extract JSON from stdout which may contain training output before the result - const result = extractJsonFromStdout(toolResult.stdout); - if (result) { - const validated = ExperimentResultSchema.safeParse(result); - if (validated.success) { - return validated.data; - } - } - } - } - } - } catch { - // Parsing failed, return null - } - return null; -}; - // --- Main --- logger.info("ETF Backtest Feature Optimization starting..."); if (verbose) { logger.debug("Verbose mode enabled"); } -await runOptimization(); +await runAgentOptimization(); logger.info("\nETF Backtest completed."); diff --git a/src/cli/etf-backtest/utils/experiment-extract.ts b/src/cli/etf-backtest/utils/experiment-extract.ts new file mode 100644 index 0000000..8f3e7f3 --- /dev/null +++ b/src/cli/etf-backtest/utils/experiment-extract.ts @@ -0,0 +1,69 @@ +import { + INDEX_NOT_FOUND, + JSON_SLICE_END_OFFSET, + ZERO, +} from "../constants"; +import { ExperimentResultSchema } from "../schemas"; +import type { ExperimentResult } from "../schemas"; + +const extractJsonFromStdout = (stdout: string): unknown => { + const startIdx = stdout.indexOf("{"); + if (startIdx === INDEX_NOT_FOUND) { + return null; + } + + let braceCount = ZERO; + let endIdx = INDEX_NOT_FOUND; + for (let i = startIdx; i < stdout.length; i++) { + if (stdout[i] === "{") { + braceCount++; + } + if (stdout[i] === "}") { + braceCount--; + } + if (braceCount === ZERO) { + endIdx = i; + break; + } + } + + if (endIdx === INDEX_NOT_FOUND) { + return null; + } + + const jsonStr = stdout.slice(startIdx, endIdx + JSON_SLICE_END_OFFSET); + return JSON.parse(jsonStr); +}; + +export const extractLastExperimentResult = (runResult: { + newItems?: { type: string; output?: unknown }[]; +}): ExperimentResult | null => { + try { + const items = runResult.newItems ?? []; + for (const item of items) { + if (item.type === "tool_call_output_item" && item.output) { + const output = item.output; + let parsed: unknown; + if (typeof output === "string") { + parsed = JSON.parse(output); + } else { + parsed = output; + } + + const toolResult = parsed as { stdout?: string }; + if (toolResult.stdout) { + const result = extractJsonFromStdout(toolResult.stdout); + if (result) { + const validated = ExperimentResultSchema.safeParse(result); + if (validated.success) { + return validated.data; + } + } + } + } + } + } catch { + // Parsing failed, return null + } + return null; +}; diff --git a/src/cli/etf-backtest/utils/final-report.ts b/src/cli/etf-backtest/utils/final-report.ts new file mode 100644 index 0000000..c623def --- /dev/null +++ b/src/cli/etf-backtest/utils/final-report.ts @@ -0,0 +1,115 @@ +import { Logger } from "~clients/logger"; + +import { + CI_LEVEL_PERCENT, + CONFIDENCE_THRESHOLDS, + DECIMAL_PLACES, + LINE_SEPARATOR, + PREDICTION_HORIZON_MONTHS, +} from "../constants"; +import type { ExperimentResult } from "../schemas"; +import { formatFixed, formatPercent } from "./formatters"; + +export const printFinalResults = ( + logger: Logger, + bestResult: ExperimentResult, + bestIteration: number, + totalIterations: number, + stopReason: string +) => { + const ciWidth = + bestResult.prediction.ci95High - bestResult.prediction.ci95Low; + let confidence = "LOW"; + if ( + bestResult.metrics.r2NonOverlapping > + CONFIDENCE_THRESHOLDS.moderate.r2NonOverlapping && + bestResult.metrics.directionAccuracyNonOverlapping > + CONFIDENCE_THRESHOLDS.moderate.directionAccuracyNonOverlapping && + ciWidth < CONFIDENCE_THRESHOLDS.moderate.maxCiWidth + ) { + confidence = "MODERATE"; + } + if ( + bestResult.metrics.r2NonOverlapping > + CONFIDENCE_THRESHOLDS.reasonable.r2NonOverlapping && + bestResult.metrics.directionAccuracyNonOverlapping > + CONFIDENCE_THRESHOLDS.reasonable.directionAccuracyNonOverlapping && + ciWidth < CONFIDENCE_THRESHOLDS.reasonable.maxCiWidth + ) { + confidence = "REASONABLE"; + } + + const lines = [ + "", + LINE_SEPARATOR, + "OPTIMIZATION COMPLETE", + LINE_SEPARATOR, + `Iterations: ${totalIterations}`, + `Best iteration: ${bestIteration}`, + `Stop reason: ${stopReason}`, + "", + "Best Feature Set:", + ...bestResult.featureIds.map((feature) => ` - ${feature}`), + "", + "Prediction Accuracy (Non-Overlapping - Honest Assessment):", + ` R²: ${formatFixed( + bestResult.metrics.r2NonOverlapping, + DECIMAL_PLACES.r2 + )}`, + ` Direction Accuracy: ${formatPercent( + bestResult.metrics.directionAccuracyNonOverlapping + )}`, + ` Independent Samples: ${bestResult.dataInfo.nonOverlappingSamples}`, + "", + "Prediction Accuracy (Overlapping - Inflated):", + ` R²: ${formatFixed( + bestResult.metrics.r2, + DECIMAL_PLACES.r2 + )}`, + ` Direction Accuracy: ${formatPercent( + bestResult.metrics.directionAccuracy + )}`, + ` MAE: ${formatPercent(bestResult.metrics.mae)}`, + ` Calibration: ${formatFixed( + bestResult.metrics.calibrationRatio, + DECIMAL_PLACES.calibration + )}`, + "", + "Backtest Metrics (Informational):", + ` Sharpe Ratio: ${formatFixed( + bestResult.metrics.sharpe, + DECIMAL_PLACES.sharpe + )}`, + ` Max Drawdown: ${formatPercent(bestResult.metrics.maxDrawdown)}`, + ` CAGR: ${formatPercent( + bestResult.metrics.cagr, + DECIMAL_PLACES.cagr + )}`, + "", + `${PREDICTION_HORIZON_MONTHS}-Month Prediction:`, + ` Expected Return: ${formatPercent(bestResult.prediction.pred12mReturn)}`, + ` ${CI_LEVEL_PERCENT}% CI: [${formatPercent( + bestResult.prediction.ci95Low + )}, ${formatPercent(bestResult.prediction.ci95High)}]`, + "", + "Uncertainty Details:", + ` Base Std: ${formatPercent( + bestResult.prediction.uncertainty.baseStd + )}`, + ` Adjusted Std: ${formatPercent( + bestResult.prediction.uncertainty.adjustedStd + )}`, + ` Extrapolation: ${ + bestResult.prediction.uncertainty.isExtrapolating + ? "Yes (features outside training range)" + : "No" + }`, + "", + `Confidence: ${confidence}`, + `Note: Non-overlapping metrics use only ${bestResult.dataInfo.nonOverlappingSamples} independent periods.`, + "Past performance does not guarantee future results.", + LINE_SEPARATOR, + ]; + + logger.info(lines.join("\n")); +}; diff --git a/src/cli/etf-backtest/utils/formatters.ts b/src/cli/etf-backtest/utils/formatters.ts new file mode 100644 index 0000000..55b4725 --- /dev/null +++ b/src/cli/etf-backtest/utils/formatters.ts @@ -0,0 +1,9 @@ +import { DECIMAL_PLACES, PERCENT_MULTIPLIER } from "../constants"; + +export const formatPercent = ( + value: number, + decimals = DECIMAL_PLACES.percent +): string => `${(value * PERCENT_MULTIPLIER).toFixed(decimals)}%`; + +export const formatFixed = (value: number, decimals: number): string => + value.toFixed(decimals); diff --git a/src/cli/etf-backtest/utils/scoring.ts b/src/cli/etf-backtest/utils/scoring.ts new file mode 100644 index 0000000..4dbd25c --- /dev/null +++ b/src/cli/etf-backtest/utils/scoring.ts @@ -0,0 +1,21 @@ +import { + NEGATIVE_SHARPE_PENALTY, + NEGATIVE_SHARPE_THRESHOLD, + SCORE_WEIGHTS, + ZERO, +} from "../constants"; +import type { ExperimentResult } from "../schemas"; + +export const computeScore = (metrics: ExperimentResult["metrics"]): number => { + // Primary: prediction accuracy on non-overlapping samples (honest assessment) + // Secondary: Sharpe < 0 is a red flag (sanity check only) + return ( + metrics.r2NonOverlapping * SCORE_WEIGHTS.r2NonOverlapping + + metrics.directionAccuracyNonOverlapping * + SCORE_WEIGHTS.directionAccuracyNonOverlapping + + metrics.mae * SCORE_WEIGHTS.mae + + (metrics.sharpe < NEGATIVE_SHARPE_THRESHOLD + ? NEGATIVE_SHARPE_PENALTY + : ZERO) + ); +}; diff --git a/src/cli/name-explorer/main.ts b/src/cli/name-explorer/main.ts index 8d95b94..06856c8 100644 --- a/src/cli/name-explorer/main.ts +++ b/src/cli/name-explorer/main.ts @@ -4,7 +4,7 @@ import "dotenv/config"; import { writeFile } from "fs/promises"; -import { Agent, MemorySession, Runner } from "@openai/agents"; +import { AgentRunner } from "~clients/agent-runner"; import { Logger } from "~clients/logger"; import { parseArgs } from "~utils/parse-args"; import { QuestionHandler } from "~utils/question-handler"; @@ -73,7 +73,7 @@ const runAiMode = async () => { tools.push(createAggregatedSqlQueryTool(aggregatedDb)); } - const agent = new Agent({ + const agentRunner = new AgentRunner({ name: "NameExpertAgent", model: "gpt-5-mini", tools, @@ -95,33 +95,11 @@ IMPORTANT: Respond with ONLY a valid JSON object: - Use status "final" when you have the answer. Put the answer in "content". - Use status "needs_clarification" only if you cannot answer without more input. Put a single, concise question in "content". When answering, do not include any questions. Do not include markdown or extra keys.`, - }); - - const runner = new Runner(); - - const toolsInProgress = new Set(); - - runner.on("agent_tool_start", (_context, _agent, tool, details) => { - const toolCall = details.toolCall as Record; - const callId = toolCall.id as string; - if (toolsInProgress.has(callId)) { - return; - } - toolsInProgress.add(callId); - - const args = String(toolCall.arguments); - logger.tool(`Calling ${tool.name}: ${args || "no arguments"}`); - }); - - runner.on("agent_tool_end", (_context, _agent, tool, result) => { - logger.tool(`${tool.name} completed`); - const preview = - result.length > 200 ? result.substring(0, 200) + "..." : result; - logger.debug(`Result: ${preview}`); + logger, + logToolArgs: true, }); const questionHandler = new QuestionHandler({ logger }); - const session = new MemorySession(); const userQuestion = await questionHandler.askString({ prompt: "Ask about Finnish names: ", @@ -133,7 +111,7 @@ When answering, do not include any questions. Do not include markdown or extra k let currentQuestion = userQuestion; while (true) { - const result = await runner.run(agent, currentQuestion, { session }); + const result = await agentRunner.run(currentQuestion); const parseResult = NameSuggesterOutputSchema.safeParse(result.finalOutput); if (!parseResult.success) { diff --git a/src/clients/agent-runner.test.ts b/src/clients/agent-runner.test.ts new file mode 100644 index 0000000..dfea57c --- /dev/null +++ b/src/clients/agent-runner.test.ts @@ -0,0 +1,388 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { Logger } from "./logger"; + +type EventHandler = (...args: unknown[]) => void; + +// Store instances for test access +let mockRunnerInstance: { + on: ReturnType; + run: ReturnType; +}; +let mockSessionInstance: object; +let eventHandlers: Map; + +vi.mock("@openai/agents", () => { + // Create fresh mocks that will be configured in beforeEach + return { + Agent: vi.fn(function MockAgent() { + return {}; + }), + Runner: vi.fn(function MockRunner() { + return mockRunnerInstance; + }), + MemorySession: vi.fn(function MockMemorySession() { + return mockSessionInstance; + }), + }; +}); + +// Import after mocking +import { AgentRunner } from "./agent-runner"; + +const getHandler = ( + handlers: Map, + event: string +): EventHandler => { + const handler = handlers.get(event); + if (!handler) { + throw new Error(`Handler for event "${event}" not found`); + } + return handler; +}; + +describe("AgentRunner", () => { + let logger: Logger; + + const TestOutputSchema = z.object({ + message: z.string(), + }); + + beforeEach(() => { + logger = new Logger({ level: "error" }); + eventHandlers = new Map(); + + mockRunnerInstance = { + on: vi.fn((event: string, handler: EventHandler) => { + eventHandlers.set(event, handler); + }), + run: vi.fn().mockResolvedValue({ finalOutput: { message: "test" } }), + }; + + mockSessionInstance = {}; + }); + + afterEach(() => { + vi.clearAllMocks(); + eventHandlers.clear(); + }); + + describe("constructor", () => { + it("registers event handlers", () => { + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + }); + + expect(mockRunnerInstance.on).toHaveBeenCalledWith( + "agent_tool_start", + expect.any(Function) + ); + expect(mockRunnerInstance.on).toHaveBeenCalledWith( + "agent_tool_end", + expect.any(Function) + ); + }); + }); + + describe("run", () => { + it("calls runner.run with agent and session", async () => { + const agentRunner = new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + }); + + await agentRunner.run("test prompt"); + + expect(mockRunnerInstance.run).toHaveBeenCalledWith( + expect.anything(), // agent + "test prompt", + { session: mockSessionInstance, maxTurns: undefined } + ); + }); + + it("passes maxTurns option", async () => { + const agentRunner = new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + }); + + await agentRunner.run("test prompt", { maxTurns: 3 }); + + expect(mockRunnerInstance.run).toHaveBeenCalledWith( + expect.anything(), + "test prompt", + { session: mockSessionInstance, maxTurns: 3 } + ); + }); + + it("returns the run result", async () => { + const expectedResult = { finalOutput: { message: "success" } }; + mockRunnerInstance.run.mockResolvedValue(expectedResult); + + const agentRunner = new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + }); + + const result = await agentRunner.run("test prompt"); + + expect(result).toBe(expectedResult); + }); + }); + + describe("event handlers", () => { + it("deduplicates tool_start events by call id", () => { + const toolLogSpy = vi.spyOn(logger, "tool"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + }); + + const handler = getHandler(eventHandlers, "agent_tool_start"); + const mockTool = { name: "testTool" }; + const mockDetails = { toolCall: { id: "call-123", arguments: "{}" } }; + + // First call should log + handler(null, null, mockTool, mockDetails); + expect(toolLogSpy).toHaveBeenCalledTimes(1); + + // Second call with same id should not log + handler(null, null, mockTool, mockDetails); + expect(toolLogSpy).toHaveBeenCalledTimes(1); + + // Different id should log + const differentDetails = { + toolCall: { id: "call-456", arguments: "{}" }, + }; + handler(null, null, mockTool, differentDetails); + expect(toolLogSpy).toHaveBeenCalledTimes(2); + }); + + it("logs tool arguments when logToolArgs is true", () => { + const toolLogSpy = vi.spyOn(logger, "tool"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + logToolArgs: true, + }); + + const handler = getHandler(eventHandlers, "agent_tool_start"); + const mockTool = { name: "testTool" }; + const mockDetails = { + toolCall: { id: "call-123", arguments: '{"key":"value"}' }, + }; + + handler(null, null, mockTool, mockDetails); + + expect(toolLogSpy).toHaveBeenCalledWith( + 'Calling testTool: {"key":"value"}' + ); + }); + + it("does not log tool arguments when logToolArgs is false", () => { + const toolLogSpy = vi.spyOn(logger, "tool"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + logToolArgs: false, + }); + + const handler = getHandler(eventHandlers, "agent_tool_start"); + const mockTool = { name: "testTool" }; + const mockDetails = { + toolCall: { id: "call-123", arguments: '{"key":"value"}' }, + }; + + handler(null, null, mockTool, mockDetails); + + expect(toolLogSpy).toHaveBeenCalledWith("Calling testTool"); + }); + + it("logs result preview when logToolResults is true", () => { + const testLogger = new Logger({ level: "debug" }); + const debugLogSpy = vi.spyOn(testLogger, "debug"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger: testLogger, + logToolResults: true, + }); + + const handler = getHandler(eventHandlers, "agent_tool_end"); + const mockTool = { name: "testTool" }; + + handler(null, null, mockTool, "short result"); + + expect(debugLogSpy).toHaveBeenCalledWith("Result: short result"); + }); + + it("truncates long results based on resultPreviewLimit", () => { + const testLogger = new Logger({ level: "debug" }); + const debugLogSpy = vi.spyOn(testLogger, "debug"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger: testLogger, + logToolResults: true, + resultPreviewLimit: 10, + }); + + const handler = getHandler(eventHandlers, "agent_tool_end"); + const mockTool = { name: "testTool" }; + + handler(null, null, mockTool, "this is a very long result string"); + + expect(debugLogSpy).toHaveBeenCalledWith("Result: this is a ..."); + }); + + it("does not log result when logToolResults is false", () => { + const testLogger = new Logger({ level: "debug" }); + const debugLogSpy = vi.spyOn(testLogger, "debug"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger: testLogger, + logToolResults: false, + }); + + const handler = getHandler(eventHandlers, "agent_tool_end"); + const mockTool = { name: "testTool" }; + + handler(null, null, mockTool, "some result"); + + expect(debugLogSpy).not.toHaveBeenCalled(); + }); + }); + + describe("memorySession", () => { + it("returns the session instance", () => { + const agentRunner = new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + }); + + expect(agentRunner.memorySession).toBe(mockSessionInstance); + }); + }); + + describe("default config values", () => { + it("defaults logToolArgs to false", () => { + const toolLogSpy = vi.spyOn(logger, "tool"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger, + }); + + const handler = getHandler(eventHandlers, "agent_tool_start"); + const mockTool = { name: "testTool" }; + const mockDetails = { + toolCall: { id: "call-123", arguments: '{"key":"value"}' }, + }; + + handler(null, null, mockTool, mockDetails); + + // Should not include arguments + expect(toolLogSpy).toHaveBeenCalledWith("Calling testTool"); + }); + + it("defaults logToolResults to true", () => { + const testLogger = new Logger({ level: "debug" }); + const debugLogSpy = vi.spyOn(testLogger, "debug"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger: testLogger, + }); + + const handler = getHandler(eventHandlers, "agent_tool_end"); + const mockTool = { name: "testTool" }; + + handler(null, null, mockTool, "result"); + + expect(debugLogSpy).toHaveBeenCalled(); + }); + + it("defaults resultPreviewLimit to 200", () => { + const testLogger = new Logger({ level: "debug" }); + const debugLogSpy = vi.spyOn(testLogger, "debug"); + + new AgentRunner({ + name: "TestAgent", + model: "gpt-4", + tools: [], + outputType: TestOutputSchema, + instructions: "Test instructions", + logger: testLogger, + }); + + const handler = getHandler(eventHandlers, "agent_tool_end"); + const mockTool = { name: "testTool" }; + const longResult = "x".repeat(250); + + handler(null, null, mockTool, longResult); + + // Should truncate at 200 chars + expect(debugLogSpy).toHaveBeenCalledWith( + "Result: " + "x".repeat(200) + "..." + ); + }); + }); +}); diff --git a/src/clients/agent-runner.ts b/src/clients/agent-runner.ts new file mode 100644 index 0000000..b29cc92 --- /dev/null +++ b/src/clients/agent-runner.ts @@ -0,0 +1,111 @@ +import { Agent, MemorySession, Runner } from "@openai/agents"; +import type { RunResult, Tool } from "@openai/agents"; +import type { ZodType } from "zod"; + +import type { Logger } from "./logger"; + +const DEFAULT_RESULT_PREVIEW_LIMIT = 200; + +export type AgentRunnerConfig = { + // Agent config + name: string; + model: string; + tools: Tool[]; + outputType: ZodType; + instructions: string; + + // Logging config + logger: Logger; + logToolArgs?: boolean; + logToolResults?: boolean; + resultPreviewLimit?: number; +}; + +export type RunOptions = { + maxTurns?: number; +}; + +type AgentType = Agent>; + +/** + * Wrapper around OpenAI Agent + Runner + MemorySession with built-in + * event logging for tool calls. Provides a consistent interface for + * running agents across different CLIs. + */ +export class AgentRunner { + private agent: AgentType; + private runner: Runner; + private session: MemorySession; + private logger: Logger; + private toolsInProgress: Set; + private logToolArgs: boolean; + private logToolResults: boolean; + private resultPreviewLimit: number; + + constructor(config: AgentRunnerConfig) { + this.logger = config.logger; + this.logToolArgs = config.logToolArgs ?? false; + this.logToolResults = config.logToolResults ?? true; + this.resultPreviewLimit = config.resultPreviewLimit ?? DEFAULT_RESULT_PREVIEW_LIMIT; + this.toolsInProgress = new Set(); + + this.agent = new Agent({ + name: config.name, + model: config.model, + tools: config.tools, + outputType: config.outputType, + instructions: config.instructions, + }); + + this.runner = new Runner(); + this.session = new MemorySession(); + + this.setupEventHandlers(); + } + + private setupEventHandlers(): void { + this.runner.on("agent_tool_start", (_context, _agent, tool, details) => { + const toolCall = details.toolCall as Record; + const callId = toolCall.id as string; + + // Deduplicate tool calls (events may fire multiple times) + if (this.toolsInProgress.has(callId)) { + return; + } + this.toolsInProgress.add(callId); + + if (this.logToolArgs) { + const args = String(toolCall.arguments); + this.logger.tool(`Calling ${tool.name}: ${args || "no arguments"}`); + } else { + this.logger.tool(`Calling ${tool.name}`); + } + }); + + this.runner.on("agent_tool_end", (_context, _agent, tool, result) => { + this.logger.tool(`${tool.name} completed`); + + if (this.logToolResults) { + const preview = + result.length > this.resultPreviewLimit + ? result.substring(0, this.resultPreviewLimit) + "..." + : result; + this.logger.debug(`Result: ${preview}`); + } + }); + } + + async run( + prompt: string, + options?: RunOptions + ): Promise>> { + return this.runner.run(this.agent, prompt, { + session: this.session, + maxTurns: options?.maxTurns, + }); + } + + get memorySession(): MemorySession { + return this.session; + } +} From 18d66db2b1ea21304d5ad4820694d69ba9488d28 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:32:15 +0200 Subject: [PATCH 03/19] refactor: simplify agent runner configuration and update model name - Remove AGENT_NAME and MODEL_NAME constants - Update model to "gpt-5-mini" in AgentRunner - Format PYTHON_BINARY path for better readability --- src/cli/etf-backtest/constants.ts | 10 ++++++---- src/cli/etf-backtest/main.ts | 6 ++---- src/clients/agent-runner.test.ts | 33 +++++++++++++++---------------- src/clients/agent-runner.ts | 5 +++-- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/cli/etf-backtest/constants.ts b/src/cli/etf-backtest/constants.ts index 52d9f2a..8708897 100644 --- a/src/cli/etf-backtest/constants.ts +++ b/src/cli/etf-backtest/constants.ts @@ -5,9 +5,6 @@ export const DEFAULT_TICKER = "SPY"; export const DEFAULT_MAX_ITERATIONS = 5; export const DEFAULT_SEED = 42; -export const AGENT_NAME = "EtfFeatureOptimizer"; -export const MODEL_NAME = "gpt-4o-mini"; - export const MAX_NO_IMPROVEMENT = 2; export const ZERO = 0; export const MAX_TURNS_PER_ITERATION = 3; @@ -74,7 +71,12 @@ export const SCRIPTS_DIR = path.join( "etf-backtest", "scripts" ); -export const PYTHON_BINARY = path.join(process.cwd(), ".venv", "bin", "python3"); +export const PYTHON_BINARY = path.join( + process.cwd(), + ".venv", + "bin", + "python3" +); export const FEATURE_MENU = { momentum: ["mom_1m", "mom_3m", "mom_6m", "mom_12m"], diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index a2c9f76..54a89d2 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -11,14 +11,12 @@ import { createRunPythonTool } from "~tools/run-python/run-python-tool"; import { parseArgs } from "~utils/parse-args"; import { - AGENT_NAME, DECIMAL_PLACES, FEATURE_MENU, MAX_FEATURES, MAX_NO_IMPROVEMENT, MAX_TURNS_PER_ITERATION, MIN_FEATURES, - MODEL_NAME, NO_IMPROVEMENT_REASON, OVERLAP_PERCENT, PREDICTION_HORIZON_MONTHS, @@ -127,8 +125,8 @@ const runAgentOptimization = async () => { }); const agentRunner = new AgentRunner({ - name: AGENT_NAME, - model: MODEL_NAME, + name: "EtfFeatureOptimizer", + model: "gpt-5-mini", tools: [runPythonTool], outputType: AgentOutputSchema, instructions: buildInstructions(), diff --git a/src/clients/agent-runner.test.ts b/src/clients/agent-runner.test.ts index dfea57c..ca2a7e7 100644 --- a/src/clients/agent-runner.test.ts +++ b/src/clients/agent-runner.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; +// Import after mocking +import { AgentRunner } from "./agent-runner"; import { Logger } from "./logger"; type EventHandler = (...args: unknown[]) => void; @@ -28,9 +30,6 @@ vi.mock("@openai/agents", () => { }; }); -// Import after mocking -import { AgentRunner } from "./agent-runner"; - const getHandler = ( handlers: Map, event: string @@ -72,7 +71,7 @@ describe("AgentRunner", () => { it("registers event handlers", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -94,7 +93,7 @@ describe("AgentRunner", () => { it("calls runner.run with agent and session", async () => { const agentRunner = new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -113,7 +112,7 @@ describe("AgentRunner", () => { it("passes maxTurns option", async () => { const agentRunner = new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -135,7 +134,7 @@ describe("AgentRunner", () => { const agentRunner = new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -154,7 +153,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -186,7 +185,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -212,7 +211,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -237,7 +236,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -259,7 +258,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -282,7 +281,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -303,7 +302,7 @@ describe("AgentRunner", () => { it("returns the session instance", () => { const agentRunner = new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -320,7 +319,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -345,7 +344,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", @@ -366,7 +365,7 @@ describe("AgentRunner", () => { new AgentRunner({ name: "TestAgent", - model: "gpt-4", + model: "gpt-5-mini", tools: [], outputType: TestOutputSchema, instructions: "Test instructions", diff --git a/src/clients/agent-runner.ts b/src/clients/agent-runner.ts index b29cc92..32596cf 100644 --- a/src/clients/agent-runner.ts +++ b/src/clients/agent-runner.ts @@ -9,7 +9,7 @@ const DEFAULT_RESULT_PREVIEW_LIMIT = 200; export type AgentRunnerConfig = { // Agent config name: string; - model: string; + model: "gpt-5-mini"; tools: Tool[]; outputType: ZodType; instructions: string; @@ -46,7 +46,8 @@ export class AgentRunner { this.logger = config.logger; this.logToolArgs = config.logToolArgs ?? false; this.logToolResults = config.logToolResults ?? true; - this.resultPreviewLimit = config.resultPreviewLimit ?? DEFAULT_RESULT_PREVIEW_LIMIT; + this.resultPreviewLimit = + config.resultPreviewLimit ?? DEFAULT_RESULT_PREVIEW_LIMIT; this.toolsInProgress = new Set(); this.agent = new Agent({ From 04886e31d12f8f6628a6717cfdee7174ce66047b Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:41:12 +0200 Subject: [PATCH 04/19] refactor: remove unused constants and clean up code --- src/cli/etf-backtest/constants.ts | 7 ------- src/cli/etf-backtest/main.ts | 4 ---- src/tools/run-python/run-python-tool.ts | 12 +++++++++++- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/cli/etf-backtest/constants.ts b/src/cli/etf-backtest/constants.ts index 8708897..b89641a 100644 --- a/src/cli/etf-backtest/constants.ts +++ b/src/cli/etf-backtest/constants.ts @@ -8,7 +8,6 @@ export const DEFAULT_SEED = 42; export const MAX_NO_IMPROVEMENT = 2; export const ZERO = 0; export const MAX_TURNS_PER_ITERATION = 3; -export const TOOL_RESULT_PREVIEW_LIMIT = 300; export const REASONING_PREVIEW_LIMIT = 100; export const MIN_FEATURES = 8; @@ -71,12 +70,6 @@ export const SCRIPTS_DIR = path.join( "etf-backtest", "scripts" ); -export const PYTHON_BINARY = path.join( - process.cwd(), - ".venv", - "bin", - "python3" -); export const FEATURE_MENU = { momentum: ["mom_1m", "mom_3m", "mom_6m", "mom_12m"], diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 54a89d2..03a6648 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -20,7 +20,6 @@ import { NO_IMPROVEMENT_REASON, OVERLAP_PERCENT, PREDICTION_HORIZON_MONTHS, - PYTHON_BINARY, REASONING_PREVIEW_LIMIT, SAMPLES_PER_DECADE, SCRIPTS_DIR, @@ -28,7 +27,6 @@ import { TARGET_CALIBRATION_MIN, TARGET_DIR_ACC_NON_OVERLAPPING, TARGET_R2_NON_OVERLAPPING, - TOOL_RESULT_PREVIEW_LIMIT, ZERO, } from "./constants"; import { AgentOutputSchema, CliArgsSchema } from "./schemas"; @@ -121,7 +119,6 @@ const runAgentOptimization = async () => { const runPythonTool = createRunPythonTool({ scriptsDir: SCRIPTS_DIR, logger, - pythonBinary: PYTHON_BINARY, }); const agentRunner = new AgentRunner({ @@ -132,7 +129,6 @@ const runAgentOptimization = async () => { instructions: buildInstructions(), logger, logToolResults: verbose, - resultPreviewLimit: TOOL_RESULT_PREVIEW_LIMIT, }); // Track state diff --git a/src/tools/run-python/run-python-tool.ts b/src/tools/run-python/run-python-tool.ts index 9f2512f..8010a9f 100644 --- a/src/tools/run-python/run-python-tool.ts +++ b/src/tools/run-python/run-python-tool.ts @@ -15,13 +15,23 @@ export type PythonResult = { error?: string; }; +/** + * Repo venv Python path for CLIs that use the project .venv. + */ +export const PYTHON_BINARY = path.join( + process.cwd(), + ".venv", + "bin", + "python3" +); + /** * Default configuration values */ const DEFAULTS = { timeoutMs: 30000, maxOutputBytes: 50 * 1024, // 50KB - pythonBinary: "python3", + pythonBinary: PYTHON_BINARY, } as const; /** From a9b6ee7808ed032279ae8eb662b9726ebf99f7ad Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:47:18 +0200 Subject: [PATCH 05/19] refactor: update agent runner to accept prompt in options object --- src/cli/etf-backtest/main.ts | 3 ++- src/cli/name-explorer/main.ts | 2 +- src/clients/agent-runner.ts | 13 +++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 03a6648..83dd8b0 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -159,7 +159,8 @@ After running the experiment, analyze the results and decide whether to continue let runResult; try { - runResult = await agentRunner.run(currentPrompt, { + runResult = await agentRunner.run({ + prompt: currentPrompt, maxTurns: MAX_TURNS_PER_ITERATION, // Limit turns per iteration: 1 tool call + 1 result + 1 output }); } catch (err) { diff --git a/src/cli/name-explorer/main.ts b/src/cli/name-explorer/main.ts index 06856c8..e3ce0e9 100644 --- a/src/cli/name-explorer/main.ts +++ b/src/cli/name-explorer/main.ts @@ -111,7 +111,7 @@ When answering, do not include any questions. Do not include markdown or extra k let currentQuestion = userQuestion; while (true) { - const result = await agentRunner.run(currentQuestion); + const result = await agentRunner.run({ prompt: currentQuestion }); const parseResult = NameSuggesterOutputSchema.safeParse(result.finalOutput); if (!parseResult.success) { diff --git a/src/clients/agent-runner.ts b/src/clients/agent-runner.ts index 32596cf..aaf6abb 100644 --- a/src/clients/agent-runner.ts +++ b/src/clients/agent-runner.ts @@ -21,7 +21,8 @@ export type AgentRunnerConfig = { resultPreviewLimit?: number; }; -export type RunOptions = { +export type RunProps = { + prompt: string; maxTurns?: number; }; @@ -96,13 +97,13 @@ export class AgentRunner { }); } - async run( - prompt: string, - options?: RunOptions - ): Promise>> { + async run({ + prompt, + ...rest + }: RunProps): Promise>> { return this.runner.run(this.agent, prompt, { session: this.session, - maxTurns: options?.maxTurns, + ...rest, }); } From 6c459a69ba3aa2dea638ef1ae79be02a2a90c23e Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:29:21 +0200 Subject: [PATCH 06/19] feat: enhance agent runner with stateless execution and logging - Implement stateless option for independent runs - Update logging for agent execution results - Refactor tests to align with new run method signature --- src/cli/guestbook/main.ts | 46 +++++++++++++++++++++++++------- src/clients/agent-runner.test.ts | 8 +++--- src/clients/agent-runner.ts | 15 ++++++++++- 3 files changed, 54 insertions(+), 15 deletions(-) diff --git a/src/cli/guestbook/main.ts b/src/cli/guestbook/main.ts index f17dda2..e872cb4 100644 --- a/src/cli/guestbook/main.ts +++ b/src/cli/guestbook/main.ts @@ -1,19 +1,28 @@ // pnpm run:guestbook -import { Agent, run } from "@openai/agents"; - import "dotenv/config"; +import { AgentRunner } from "~clients/agent-runner"; +import { Logger } from "~clients/logger"; import { readFileTool } from "~tools/read-file/read-file-tool"; import { writeFileTool } from "~tools/write-file/write-file-tool"; +import { z } from "zod"; import { question } from "zx"; -console.log("Guestbook running..."); +const logger = new Logger(); + +logger.info("Guestbook running..."); + +const OutputSchema = z.object({ + success: z.boolean(), + message: z.string(), +}); -const agent = new Agent({ +const agentRunner = new AgentRunner({ name: "GuestbookAgent", model: "gpt-5-mini", tools: [writeFileTool, readFileTool], + outputType: OutputSchema, instructions: ` You maintain a shared "greeting guestbook" at guestbook.md. Rules: @@ -23,7 +32,12 @@ Rules: - If it doesn't exist, create it with a header and an Entries section. - Each entry must include the user's name. - Keep it upbeat and a little nerdy, but not cringe. + +IMPORTANT: Always respond with a JSON object in this format: +{"success": true/false, "message": "description of what was done"} `, + logger, + stateless: true, // Each run is independent }); const userName = await question("Enter user name: "); @@ -53,11 +67,23 @@ Steps: 4) Write the final Markdown back to guestbook.md. `; -const result = await run(agent, prompt); +const result = await agentRunner.run({ prompt }); +const parseResult = OutputSchema.safeParse(result.finalOutput); -console.log("Agent result:", result.finalOutput); +if (parseResult.success) { + logger.info(`Result: ${parseResult.data.message}`); +} else { + logger.warn("Unexpected response format"); + logger.info(String(result.finalOutput)); +} -// Optional: show the file contents after write -const preview = await run(agent, `Read and print the contents of guestbook.md`); -console.log("\n--- Preview ---\n"); -console.log(preview.finalOutput); +// Show the file contents after write +const preview = await agentRunner.run({ + prompt: `Read guestbook.md and include its full contents in your response message.`, +}); +const previewResult = OutputSchema.safeParse(preview.finalOutput); +if (previewResult.success) { + logger.answer(previewResult.data.message); +} else { + logger.answer(JSON.stringify(preview.finalOutput, null, 2)); +} diff --git a/src/clients/agent-runner.test.ts b/src/clients/agent-runner.test.ts index ca2a7e7..50678ea 100644 --- a/src/clients/agent-runner.test.ts +++ b/src/clients/agent-runner.test.ts @@ -100,12 +100,12 @@ describe("AgentRunner", () => { logger, }); - await agentRunner.run("test prompt"); + await agentRunner.run({ prompt: "test prompt" }); expect(mockRunnerInstance.run).toHaveBeenCalledWith( expect.anything(), // agent "test prompt", - { session: mockSessionInstance, maxTurns: undefined } + { session: mockSessionInstance } ); }); @@ -119,7 +119,7 @@ describe("AgentRunner", () => { logger, }); - await agentRunner.run("test prompt", { maxTurns: 3 }); + await agentRunner.run({ prompt: "test prompt", maxTurns: 3 }); expect(mockRunnerInstance.run).toHaveBeenCalledWith( expect.anything(), @@ -141,7 +141,7 @@ describe("AgentRunner", () => { logger, }); - const result = await agentRunner.run("test prompt"); + const result = await agentRunner.run({ prompt: "test prompt" }); expect(result).toBe(expectedResult); }); diff --git a/src/clients/agent-runner.ts b/src/clients/agent-runner.ts index aaf6abb..e740cf1 100644 --- a/src/clients/agent-runner.ts +++ b/src/clients/agent-runner.ts @@ -19,11 +19,19 @@ export type AgentRunnerConfig = { logToolArgs?: boolean; logToolResults?: boolean; resultPreviewLimit?: number; + + /** + * If true, each run() call uses a fresh context (no session history). + * Required for reasoning models (gpt-5-mini) when making multiple independent runs. + */ + stateless?: boolean; }; export type RunProps = { prompt: string; maxTurns?: number; + /** If true, run without session history (fresh context). Useful for independent follow-up queries. */ + stateless?: boolean; }; type AgentType = Agent>; @@ -42,6 +50,7 @@ export class AgentRunner { private logToolArgs: boolean; private logToolResults: boolean; private resultPreviewLimit: number; + private stateless: boolean; constructor(config: AgentRunnerConfig) { this.logger = config.logger; @@ -50,6 +59,7 @@ export class AgentRunner { this.resultPreviewLimit = config.resultPreviewLimit ?? DEFAULT_RESULT_PREVIEW_LIMIT; this.toolsInProgress = new Set(); + this.stateless = config.stateless ?? false; this.agent = new Agent({ name: config.name, @@ -101,8 +111,11 @@ export class AgentRunner { prompt, ...rest }: RunProps): Promise>> { + // When stateless=true, omit session to avoid reasoning item sequence errors + // that occur when reusing MemorySession with reasoning models + const sessionOption = this.stateless ? {} : { session: this.session }; return this.runner.run(this.agent, prompt, { - session: this.session, + ...sessionOption, ...rest, }); } From 51485a9921ea5f7cb1ae5b8d79339d1ea6498a7b Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:39:06 +0200 Subject: [PATCH 07/19] refactor: streamline tool creation with logger integration - Replace existing tools with factory functions that accept logger - Update tests to utilize new tool creation methods --- src/cli/etf-backtest/README.md | 18 +-- .../etf-backtest/utils/experiment-extract.ts | 6 +- src/cli/guestbook/main.ts | 6 +- src/tools/fetch-url/fetch-url-tool.test.ts | 57 ++++---- src/tools/fetch-url/fetch-url-tool.ts | 133 +++++++++--------- src/tools/list-files/list-files-tool.test.ts | 12 +- src/tools/list-files/list-files-tool.ts | 66 +++++---- src/tools/read-file/read-file-tool.test.ts | 10 +- src/tools/read-file/read-file-tool.ts | 46 +++--- src/tools/write-file/write-file-tool.test.ts | 10 +- src/tools/write-file/write-file-tool.ts | 64 +++++---- 11 files changed, 234 insertions(+), 194 deletions(-) diff --git a/src/cli/etf-backtest/README.md b/src/cli/etf-backtest/README.md index 5a1b853..c0da63a 100644 --- a/src/cli/etf-backtest/README.md +++ b/src/cli/etf-backtest/README.md @@ -51,20 +51,20 @@ The agent selects 8-12 features from these categories: ### Prediction Accuracy (Primary - Optimization Target) -| Metric | Description | -| ------------------------------------ | -------------------------------------------------- | -| `r2NonOverlapping` | R² on non-overlapping 12-month windows (honest) | +| Metric | Description | +| --------------------------------- | -------------------------------------------------- | +| `r2NonOverlapping` | R² on non-overlapping 12-month windows (honest) | | `directionAccuracyNonOverlapping` | Sign prediction accuracy on independent periods | -| `mae` | Mean absolute error of 12-month return predictions | -| `calibrationRatio` | Predicted std / actual std (target: 0.8-1.2) | +| `mae` | Mean absolute error of 12-month return predictions | +| `calibrationRatio` | Predicted std / actual std (target: 0.8-1.2) | ### Backtest Metrics (Informational Only) -| Metric | Description | -| -------------- | -------------------------------------- | -| `sharpe` | Sharpe ratio of daily trading strategy | +| Metric | Description | +| ------------- | -------------------------------------- | +| `sharpe` | Sharpe ratio of daily trading strategy | | `maxDrawdown` | Maximum peak-to-trough decline | -| `cagr` | Compound annual growth rate | +| `cagr` | Compound annual growth rate | ### Why Non-Overlapping? diff --git a/src/cli/etf-backtest/utils/experiment-extract.ts b/src/cli/etf-backtest/utils/experiment-extract.ts index 8f3e7f3..903abf2 100644 --- a/src/cli/etf-backtest/utils/experiment-extract.ts +++ b/src/cli/etf-backtest/utils/experiment-extract.ts @@ -1,8 +1,4 @@ -import { - INDEX_NOT_FOUND, - JSON_SLICE_END_OFFSET, - ZERO, -} from "../constants"; +import { INDEX_NOT_FOUND, JSON_SLICE_END_OFFSET, ZERO } from "../constants"; import { ExperimentResultSchema } from "../schemas"; import type { ExperimentResult } from "../schemas"; diff --git a/src/cli/guestbook/main.ts b/src/cli/guestbook/main.ts index e872cb4..94e1bb9 100644 --- a/src/cli/guestbook/main.ts +++ b/src/cli/guestbook/main.ts @@ -4,8 +4,8 @@ import "dotenv/config"; import { AgentRunner } from "~clients/agent-runner"; import { Logger } from "~clients/logger"; -import { readFileTool } from "~tools/read-file/read-file-tool"; -import { writeFileTool } from "~tools/write-file/write-file-tool"; +import { createReadFileTool } from "~tools/read-file/read-file-tool"; +import { createWriteFileTool } from "~tools/write-file/write-file-tool"; import { z } from "zod"; import { question } from "zx"; @@ -21,7 +21,7 @@ const OutputSchema = z.object({ const agentRunner = new AgentRunner({ name: "GuestbookAgent", model: "gpt-5-mini", - tools: [writeFileTool, readFileTool], + tools: [createWriteFileTool({ logger }), createReadFileTool({ logger })], outputType: OutputSchema, instructions: ` You maintain a shared "greeting guestbook" at guestbook.md. diff --git a/src/tools/fetch-url/fetch-url-tool.test.ts b/src/tools/fetch-url/fetch-url-tool.test.ts index c8ee32e..7728793 100644 --- a/src/tools/fetch-url/fetch-url-tool.test.ts +++ b/src/tools/fetch-url/fetch-url-tool.test.ts @@ -3,7 +3,10 @@ import * as urlSafety from "~tools/utils/url-safety"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { FetchResult } from "./fetch-url-tool"; -import { fetchUrlTool } from "./fetch-url-tool"; +import { createFetchUrlTool } from "./fetch-url-tool"; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const mockLogger = { tool: () => {} } as never; // Mock the url-safety module vi.mock("~tools/utils/url-safety", async (importOriginal) => { @@ -59,7 +62,7 @@ const createMockResponse = (options: { const parseResult = (result: string): FetchResult => JSON.parse(result) as FetchResult; -describe("fetchUrlTool", () => { +describe("createFetchUrlTool", () => { beforeEach(() => { vi.resetAllMocks(); vi.stubGlobal("fetch", vi.fn()); @@ -83,7 +86,7 @@ describe("fetchUrlTool", () => { }); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "http://localhost/secret", }) ); @@ -100,7 +103,7 @@ describe("fetchUrlTool", () => { }); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "http://192.168.1.1/admin", }) ); @@ -116,7 +119,7 @@ describe("fetchUrlTool", () => { }); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "http://169.254.169.254/latest/meta-data/", }) ); @@ -143,7 +146,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/redirect", }) ); @@ -178,7 +181,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page1", }) ); @@ -203,7 +206,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/start", maxRedirects: 2, }) @@ -227,7 +230,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/bad-redirect", }) ); @@ -257,7 +260,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/start", }) ); @@ -276,7 +279,7 @@ describe("fetchUrlTool", () => { }) ); - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", etag: '"abc123"', }); @@ -297,7 +300,7 @@ describe("fetchUrlTool", () => { }) ); - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", lastModified: "Wed, 21 Oct 2024 07:28:00 GMT", }); @@ -320,7 +323,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", etag: '"abc123"', }) @@ -342,7 +345,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -361,7 +364,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -381,7 +384,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/large", maxBytes: 1024, }) @@ -403,7 +406,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/large", maxChars: 1000, }) @@ -423,7 +426,7 @@ describe("fetchUrlTool", () => { }); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/slow", timeoutMs: 1000, }) @@ -446,7 +449,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -466,7 +469,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -485,7 +488,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -502,7 +505,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -520,7 +523,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -539,7 +542,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); @@ -560,7 +563,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/file.txt", }) ); @@ -579,7 +582,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/api/data", }) ); @@ -599,7 +602,7 @@ describe("fetchUrlTool", () => { ); const result = parseResult( - await invokeTool(fetchUrlTool, { + await invokeTool(createFetchUrlTool({ logger: mockLogger }), { url: "https://example.com/page", }) ); diff --git a/src/tools/fetch-url/fetch-url-tool.ts b/src/tools/fetch-url/fetch-url-tool.ts index 0edbf9b..f4774bd 100644 --- a/src/tools/fetch-url/fetch-url-tool.ts +++ b/src/tools/fetch-url/fetch-url-tool.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { tool } from "@openai/agents"; +import type { Logger } from "~clients/logger"; import { processHtmlContent } from "~tools/utils/html-processing"; import { resolveAndValidateUrl } from "~tools/utils/url-safety"; @@ -358,75 +359,75 @@ const executeFetch = async (params: { } }; +export type FetchUrlToolOptions = { + logger: Logger; +}; + /** * Safe HTTP GET fetch tool for agent runtime. * Fetches web pages with SSRF protection, HTML sanitization, and Markdown conversion. */ -export const fetchUrlTool = tool({ - name: "fetchUrl", - description: - "Fetches a web page via HTTP GET and returns clean, sanitized Markdown content. " + - "Includes SSRF protection (blocks localhost, private IPs, cloud metadata endpoints). " + - "HTML content is sanitized to remove scripts, iframes, and event handlers before conversion.", - parameters: { - type: "object", - properties: { - url: { - type: "string", - description: "The URL to fetch (must be http or https)", - }, - timeoutMs: { - type: "number", - description: - "Request timeout in milliseconds (default: 15000, max: 30000)", - }, - maxBytes: { - type: "number", - description: - "Maximum response size in bytes (default: 2097152 / 2MB, max: 5242880 / 5MB)", - }, - maxRedirects: { - type: "number", - description: - "Maximum number of redirects to follow (default: 5, max: 10)", - }, - maxChars: { - type: "number", - description: - "Maximum characters in output markdown/text (default: 50000)", - }, - etag: { - type: "string", - description: "ETag from previous request for conditional fetch", - }, - lastModified: { - type: "string", - description: - "Last-Modified value from previous request for conditional fetch", +export const createFetchUrlTool = ({ logger }: FetchUrlToolOptions) => + tool({ + name: "fetchUrl", + description: + "Fetches a web page via HTTP GET and returns clean, sanitized Markdown content. " + + "Includes SSRF protection (blocks localhost, private IPs, cloud metadata endpoints). " + + "HTML content is sanitized to remove scripts, iframes, and event handlers before conversion.", + parameters: { + type: "object", + properties: { + url: { + type: "string", + description: "The URL to fetch (must be http or https)", + }, + timeoutMs: { + type: "number", + description: + "Request timeout in milliseconds (default: 15000, max: 30000)", + }, + maxBytes: { + type: "number", + description: + "Maximum response size in bytes (default: 2097152 / 2MB, max: 5242880 / 5MB)", + }, + maxRedirects: { + type: "number", + description: + "Maximum number of redirects to follow (default: 5, max: 10)", + }, + maxChars: { + type: "number", + description: + "Maximum characters in output markdown/text (default: 50000)", + }, + etag: { + type: "string", + description: "ETag from previous request for conditional fetch", + }, + lastModified: { + type: "string", + description: + "Last-Modified value from previous request for conditional fetch", + }, }, + required: ["url"], + additionalProperties: false, + }, + execute: async (params: { + url: string; + timeoutMs?: number; + maxBytes?: number; + maxRedirects?: number; + maxChars?: number; + etag?: string; + lastModified?: string; + }) => { + logger.tool(`Fetching URL: ${params.url}`); + const result = await executeFetch(params); + logger.tool( + `Fetch result: ok=${result.ok}, status=${result.status}, finalUrl=${result.finalUrl}${result.error ? `, error=${result.error}` : ""}` + ); + return JSON.stringify(result, null, 2); }, - required: ["url"], - additionalProperties: false, - }, - execute: async (params: { - url: string; - timeoutMs?: number; - maxBytes?: number; - maxRedirects?: number; - maxChars?: number; - etag?: string; - lastModified?: string; - }) => { - console.log("Fetching URL:", params.url); - const result = await executeFetch(params); - console.log("Fetch result:", { - ok: result.ok, - status: result.status, - finalUrl: result.finalUrl, - hasMarkdown: !!result.markdown, - hasText: !!result.text, - error: result.error, - }); - return JSON.stringify(result, null, 2); - }, -}); + }); diff --git a/src/tools/list-files/list-files-tool.test.ts b/src/tools/list-files/list-files-tool.test.ts index 660d3f7..106a32d 100644 --- a/src/tools/list-files/list-files-tool.test.ts +++ b/src/tools/list-files/list-files-tool.test.ts @@ -4,11 +4,13 @@ import { TMP_ROOT } from "~tools/utils/fs"; import { invokeTool, tryCreateSymlink } from "~tools/utils/test-utils"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { listFilesTool } from "./list-files-tool"; +import { createListFilesTool } from "./list-files-tool"; -describe("listFilesTool tmp path safety", () => { +describe("createListFilesTool tmp path safety", () => { let testDir = ""; let relativeDir = ""; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const mockLogger = { tool: () => {} } as never; beforeEach(async () => { await fs.mkdir(TMP_ROOT, { recursive: true }); @@ -29,6 +31,7 @@ describe("listFilesTool tmp path safety", () => { await fs.writeFile(path.join(testDir, "file2.txt"), "content2", "utf8"); await fs.mkdir(path.join(testDir, "subdir"), { recursive: true }); + const listFilesTool = createListFilesTool({ logger: mockLogger }); const result = await invokeTool(listFilesTool, { path: relativeDir, }); @@ -41,6 +44,7 @@ describe("listFilesTool tmp path safety", () => { it("lists files with absolute paths under tmp", async () => { await fs.writeFile(path.join(testDir, "absolute.txt"), "content", "utf8"); + const listFilesTool = createListFilesTool({ logger: mockLogger }); const result = await invokeTool(listFilesTool, { path: testDir, }); @@ -49,6 +53,7 @@ describe("listFilesTool tmp path safety", () => { }); it("lists root of tmp when no path provided", async () => { + const listFilesTool = createListFilesTool({ logger: mockLogger }); const result = await invokeTool(listFilesTool, {}); expect(result).toContain("Contents of tmp:"); @@ -56,6 +61,7 @@ describe("listFilesTool tmp path safety", () => { }); it("rejects path traversal attempts", async () => { + const listFilesTool = createListFilesTool({ logger: mockLogger }); const result = await invokeTool(listFilesTool, { path: "../", }); @@ -74,6 +80,7 @@ describe("listFilesTool tmp path safety", () => { const symlinkPath = path.join(relativeDir, "link"); + const listFilesTool = createListFilesTool({ logger: mockLogger }); const result = await invokeTool(listFilesTool, { path: symlinkPath, }); @@ -84,6 +91,7 @@ describe("listFilesTool tmp path safety", () => { const emptyDir = path.join(testDir, "empty"); await fs.mkdir(emptyDir, { recursive: true }); + const listFilesTool = createListFilesTool({ logger: mockLogger }); const result = await invokeTool(listFilesTool, { path: path.join(relativeDir, "empty"), }); diff --git a/src/tools/list-files/list-files-tool.ts b/src/tools/list-files/list-files-tool.ts index 466743d..0dc2a5d 100644 --- a/src/tools/list-files/list-files-tool.ts +++ b/src/tools/list-files/list-files-tool.ts @@ -1,39 +1,45 @@ import fs from "node:fs/promises"; import path from "node:path"; import { tool } from "@openai/agents"; +import type { Logger } from "~clients/logger"; import { resolveTmpPathForList, TMP_ROOT } from "~tools/utils/fs"; -export const listFilesTool = tool({ - name: "listFiles", - description: - "Lists files and directories under the repo tmp directory (path is relative to tmp). If no path provided, lists root of tmp.", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: - "Relative path within the repo tmp directory (optional, defaults to tmp root)", +export type ListFilesToolOptions = { + logger: Logger; +}; + +export const createListFilesTool = ({ logger }: ListFilesToolOptions) => + tool({ + name: "listFiles", + description: + "Lists files and directories under the repo tmp directory (path is relative to tmp). If no path provided, lists root of tmp.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: + "Relative path within the repo tmp directory (optional, defaults to tmp root)", + }, }, + required: [], + additionalProperties: false, }, - required: [], - additionalProperties: false, - }, - execute: async ({ path: dirPath }: { path?: string }) => { - console.log("Listing files at path:", dirPath ?? "(tmp root)"); - const targetPath = await resolveTmpPathForList(dirPath); - console.log("Resolved target path:", targetPath); + execute: async ({ path: dirPath }: { path?: string }) => { + logger.tool(`Listing files: ${dirPath ?? "tmp root"}`); + const targetPath = await resolveTmpPathForList(dirPath); - const entries = await fs.readdir(targetPath, { withFileTypes: true }); - const lines = entries.map((entry) => { - const type = entry.isDirectory() ? "[dir] " : "[file]"; - return `${type} ${entry.name}`; - }); + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + const lines = entries.map((entry) => { + const type = entry.isDirectory() ? "[dir] " : "[file]"; + return `${type} ${entry.name}`; + }); - const relativePath = path.relative(TMP_ROOT, targetPath); - const displayPath = relativePath || "tmp"; - return lines.length > 0 - ? `Contents of ${displayPath}:\n${lines.join("\n")}` - : `${displayPath} is empty`; - }, -}); + const relativePath = path.relative(TMP_ROOT, targetPath); + const displayPath = relativePath || "tmp"; + logger.tool(`Listed ${entries.length} entries in ${displayPath}`); + return lines.length > 0 + ? `Contents of ${displayPath}:\n${lines.join("\n")}` + : `${displayPath} is empty`; + }, + }); diff --git a/src/tools/read-file/read-file-tool.test.ts b/src/tools/read-file/read-file-tool.test.ts index e23b2af..eba6541 100644 --- a/src/tools/read-file/read-file-tool.test.ts +++ b/src/tools/read-file/read-file-tool.test.ts @@ -4,11 +4,13 @@ import { TMP_ROOT } from "~tools/utils/fs"; import { invokeTool, tryCreateSymlink } from "~tools/utils/test-utils"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { readFileTool } from "./read-file-tool"; +import { createReadFileTool } from "./read-file-tool"; -describe("readFileTool tmp path safety", () => { +describe("createReadFileTool tmp path safety", () => { let testDir = ""; let relativeDir = ""; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const mockLogger = { tool: () => {} } as never; beforeEach(async () => { await fs.mkdir(TMP_ROOT, { recursive: true }); @@ -29,6 +31,7 @@ describe("readFileTool tmp path safety", () => { const content = "hello"; await fs.writeFile(path.join(TMP_ROOT, relativePath), content, "utf8"); + const readFileTool = createReadFileTool({ logger: mockLogger }); const readResult = await invokeTool(readFileTool, { path: relativePath, }); @@ -40,6 +43,7 @@ describe("readFileTool tmp path safety", () => { const content = "absolute"; await fs.writeFile(absolutePath, content, "utf8"); + const readFileTool = createReadFileTool({ logger: mockLogger }); const readResult = await invokeTool(readFileTool, { path: absolutePath, }); @@ -47,6 +51,7 @@ describe("readFileTool tmp path safety", () => { }); it("rejects path traversal attempts", async () => { + const readFileTool = createReadFileTool({ logger: mockLogger }); const readResult = await invokeTool(readFileTool, { path: "../outside.txt", }); @@ -65,6 +70,7 @@ describe("readFileTool tmp path safety", () => { const symlinkPath = path.join(relativeDir, "link", "file.txt"); + const readFileTool = createReadFileTool({ logger: mockLogger }); const readResult = await invokeTool(readFileTool, { path: symlinkPath, }); diff --git a/src/tools/read-file/read-file-tool.ts b/src/tools/read-file/read-file-tool.ts index fa32179..3da6a70 100644 --- a/src/tools/read-file/read-file-tool.ts +++ b/src/tools/read-file/read-file-tool.ts @@ -1,26 +1,32 @@ import fs from "node:fs/promises"; import { tool } from "@openai/agents"; +import type { Logger } from "~clients/logger"; import { resolveTmpPathForRead } from "~tools/utils/fs"; -export const readFileTool = tool({ - name: "readFile", - description: - "Reads content from a file under the repo tmp directory (path is relative to tmp).", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative path within the repo tmp directory", +export type ReadFileToolOptions = { + logger: Logger; +}; + +export const createReadFileTool = ({ logger }: ReadFileToolOptions) => + tool({ + name: "readFile", + description: + "Reads content from a file under the repo tmp directory (path is relative to tmp).", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative path within the repo tmp directory", + }, }, + required: ["path"], + additionalProperties: false, + }, + execute: async ({ path: filePath }: { path: string }) => { + logger.tool(`Reading file: ${filePath}`); + const targetPath = await resolveTmpPathForRead(filePath); + logger.tool(`Read file result: ${targetPath}`); + return fs.readFile(targetPath, "utf8"); }, - required: ["path"], - additionalProperties: false, - }, - execute: async ({ path: filePath }: { path: string }) => { - console.log("Reading file at path:", filePath); - const targetPath = await resolveTmpPathForRead(filePath); - console.log("Resolved target path:", targetPath); - return fs.readFile(targetPath, "utf8"); - }, -}); + }); diff --git a/src/tools/write-file/write-file-tool.test.ts b/src/tools/write-file/write-file-tool.test.ts index 18fee0a..563624f 100644 --- a/src/tools/write-file/write-file-tool.test.ts +++ b/src/tools/write-file/write-file-tool.test.ts @@ -4,11 +4,13 @@ import { TMP_ROOT } from "~tools/utils/fs"; import { invokeTool, tryCreateSymlink } from "~tools/utils/test-utils"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { writeFileTool } from "./write-file-tool"; +import { createWriteFileTool } from "./write-file-tool"; -describe("writeFileTool tmp path safety", () => { +describe("createWriteFileTool tmp path safety", () => { let testDir = ""; let relativeDir = ""; + // eslint-disable-next-line @typescript-eslint/no-empty-function + const mockLogger = { tool: () => {} } as never; beforeEach(async () => { await fs.mkdir(TMP_ROOT, { recursive: true }); @@ -28,6 +30,7 @@ describe("writeFileTool tmp path safety", () => { const relativePath = path.join(relativeDir, "relative.txt"); const content = "hello"; + const writeFileTool = createWriteFileTool({ logger: mockLogger }); const writeResult = await invokeTool(writeFileTool, { path: relativePath, content, @@ -44,6 +47,7 @@ describe("writeFileTool tmp path safety", () => { const absolutePath = path.join(testDir, "absolute.txt"); const content = "absolute"; + const writeFileTool = createWriteFileTool({ logger: mockLogger }); const writeResult = await invokeTool(writeFileTool, { path: absolutePath, content, @@ -54,6 +58,7 @@ describe("writeFileTool tmp path safety", () => { }); it("rejects path traversal attempts", async () => { + const writeFileTool = createWriteFileTool({ logger: mockLogger }); const writeResult = await invokeTool(writeFileTool, { path: "../outside.txt", content: "nope", @@ -73,6 +78,7 @@ describe("writeFileTool tmp path safety", () => { const symlinkPath = path.join(relativeDir, "link", "file.txt"); + const writeFileTool = createWriteFileTool({ logger: mockLogger }); const writeResult = await invokeTool(writeFileTool, { path: symlinkPath, content: "nope", diff --git a/src/tools/write-file/write-file-tool.ts b/src/tools/write-file/write-file-tool.ts index 2d94482..e5cbdc1 100644 --- a/src/tools/write-file/write-file-tool.ts +++ b/src/tools/write-file/write-file-tool.ts @@ -1,35 +1,43 @@ import fs from "node:fs/promises"; import path from "node:path"; import { tool } from "@openai/agents"; +import type { Logger } from "~clients/logger"; import { resolveTmpPathForWrite, TMP_ROOT } from "~tools/utils/fs"; -export const writeFileTool = tool({ - name: "writeFile", - description: - "Writes content to a file under the repo tmp directory (path is relative to tmp).", - parameters: { - type: "object", - properties: { - path: { - type: "string", - description: "Relative path within the repo tmp directory", +export type WriteFileToolOptions = { + logger: Logger; +}; + +export const createWriteFileTool = ({ logger }: WriteFileToolOptions) => + tool({ + name: "writeFile", + description: + "Writes content to a file under the repo tmp directory (path is relative to tmp).", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Relative path within the repo tmp directory", + }, + content: { type: "string", description: "The content to write" }, }, - content: { type: "string", description: "The content to write" }, + required: ["path", "content"], + additionalProperties: false, + }, + execute: async ({ + path: filePath, + content, + }: { + path: string; + content: string; + }) => { + logger.tool(`Writing file: ${filePath}`); + const targetPath = await resolveTmpPathForWrite(filePath); + await fs.writeFile(targetPath, content, "utf8"); + const relativePath = path.relative(TMP_ROOT, targetPath); + const bytes = Buffer.byteLength(content, "utf8"); + logger.tool(`Wrote ${bytes} bytes to tmp/${relativePath}`); + return `Wrote ${bytes} bytes to tmp/${relativePath}`; }, - required: ["path", "content"], - additionalProperties: false, - }, - execute: async ({ - path: filePath, - content, - }: { - path: string; - content: string; - }) => { - console.log("Writing file at path:", filePath); - const targetPath = await resolveTmpPathForWrite(filePath); - await fs.writeFile(targetPath, content, "utf8"); - const relativePath = path.relative(TMP_ROOT, targetPath); - return `Wrote ${Buffer.byteLength(content, "utf8")} bytes to tmp/${relativePath}`; - }, -}); + }); From 40bcff9ed368254ccbae4eac4606871703daa75c Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:08:17 +0200 Subject: [PATCH 08/19] refactor: enhance logging structure and clarity across multiple files - Replace template literals in logger calls with structured objects - Improve log messages for better readability and consistency - Update logger calls in various tools and main files --- eslint.config.ts | 16 +++ src/cli/etf-backtest/main.ts | 49 ++++---- src/cli/etf-backtest/utils/final-report.ts | 2 +- src/cli/guestbook/main.ts | 2 +- src/cli/name-explorer/clients/database.ts | 14 ++- src/cli/name-explorer/clients/pipeline.ts | 41 ++++--- src/cli/name-explorer/main.ts | 2 +- .../clients/publication-pipeline.ts | 113 +++++++++++------- .../clients/publication-scraper.ts | 20 ++-- src/cli/scrape-publications/main.ts | 2 +- src/clients/agent-runner.ts | 11 +- src/clients/playwright-scraper.ts | 47 +++++--- src/tools/fetch-url/fetch-url-tool.ts | 11 +- src/tools/list-files/list-files-tool.ts | 7 +- src/tools/read-file/read-file-tool.ts | 4 +- src/tools/run-python/run-python-tool.ts | 11 +- src/tools/write-file/write-file-tool.ts | 7 +- src/utils/parse-args.ts | 2 +- src/utils/question-handler.ts | 8 +- 19 files changed, 228 insertions(+), 141 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index c8d2d0a..c29cb54 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -89,6 +89,22 @@ export default defineConfig( ], }, ], + // Avoid template literals in logger calls for better structured logging + "no-restricted-syntax": [ + "error", + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.object.name='logger'][callee.property.name=/^(debug|info|warn|error|tool|question|answer)$/] > TemplateLiteral", + message: + "Avoid template literals in logger calls. Use a plain string and pass data as extra args (e.g. logger.info('Saved file', { path })).", + }, + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.object.type='MemberExpression'][callee.object.property.name='logger'][callee.property.name=/^(debug|info|warn|error|tool|question|answer)$/] > TemplateLiteral", + message: + "Avoid template literals in logger calls. Use a plain string and pass data as extra args (e.g. logger.info('Saved file', { path })).", + }, + ], }, }, { diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 83dd8b0..ce34557 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -155,7 +155,7 @@ After running the experiment, analyze the results and decide whether to continue while (iteration < maxIterations) { iteration++; - logger.info(`\n--- Iteration ${iteration}/${maxIterations} ---`); + logger.info("\n--- Iteration ---", { iteration, maxIterations }); let runResult; try { @@ -199,7 +199,7 @@ After running the experiment, analyze the results and decide whether to continue if (!parseResult.success) { logger.warn("Invalid agent response format, continuing..."); if (verbose) { - logger.debug(`Parse error: ${JSON.stringify(parseResult.error)}`); + logger.debug("Parse error", { error: parseResult.error }); } currentPrompt = "Your response was not valid JSON. Please respond with the correct format."; @@ -207,35 +207,35 @@ After running the experiment, analyze the results and decide whether to continue } const output = parseResult.data; - logger.info(`Features: ${output.selectedFeatures.join(", ")}`); - logger.info( - `Reasoning: ${output.reasoning.substring(ZERO, REASONING_PREVIEW_LIMIT)}...` - ); + logger.info("Features selected", { features: output.selectedFeatures }); + logger.info("Reasoning preview", { + preview: output.reasoning.substring(ZERO, REASONING_PREVIEW_LIMIT), + }); // Try to extract experiment result from the tool call outputs const lastToolResult = extractLastExperimentResult(runResult); if (lastToolResult) { const score = computeScore(lastToolResult.metrics); - logger.info( - `Prediction: R²_no=${formatFixed( + logger.info("Prediction metrics", { + r2NonOverlapping: formatFixed( lastToolResult.metrics.r2NonOverlapping, DECIMAL_PLACES.r2 - )}, ` + - `DirAcc_no=${formatPercent( - lastToolResult.metrics.directionAccuracyNonOverlapping - )}, ` + - `MAE=${formatPercent( - lastToolResult.metrics.mae - )}, Score=${formatFixed(score, DECIMAL_PLACES.score)}` - ); + ), + directionAccuracyNonOverlapping: formatPercent( + lastToolResult.metrics.directionAccuracyNonOverlapping + ), + mae: formatPercent(lastToolResult.metrics.mae), + score: formatFixed(score, DECIMAL_PLACES.score), + }); if (verbose) { - logger.debug( - `Backtest: Sharpe=${formatFixed( + logger.debug("Backtest metrics", { + sharpe: formatFixed( lastToolResult.metrics.sharpe, DECIMAL_PLACES.sharpe - )}, ` + `MaxDD=${formatPercent(lastToolResult.metrics.maxDrawdown)}` - ); + ), + maxDrawdown: formatPercent(lastToolResult.metrics.maxDrawdown), + }); } if (score > bestScore) { @@ -246,16 +246,17 @@ After running the experiment, analyze the results and decide whether to continue logger.info("New best result!"); } else { noImprovementCount++; - logger.info( - `No improvement (${noImprovementCount}/${MAX_NO_IMPROVEMENT})` - ); + logger.info("No improvement", { + noImprovementCount, + maxNoImprovement: MAX_NO_IMPROVEMENT, + }); } } // Check stop conditions if (output.status === "final") { stopReason = output.stopReason ?? "Agent decided to stop"; - logger.info(`Agent stopped: ${stopReason}`); + logger.info("Agent stopped", { stopReason }); break; } diff --git a/src/cli/etf-backtest/utils/final-report.ts b/src/cli/etf-backtest/utils/final-report.ts index c623def..82b4fd2 100644 --- a/src/cli/etf-backtest/utils/final-report.ts +++ b/src/cli/etf-backtest/utils/final-report.ts @@ -1,4 +1,4 @@ -import { Logger } from "~clients/logger"; +import type { Logger } from "~clients/logger"; import { CI_LEVEL_PERCENT, diff --git a/src/cli/guestbook/main.ts b/src/cli/guestbook/main.ts index 94e1bb9..a4013e6 100644 --- a/src/cli/guestbook/main.ts +++ b/src/cli/guestbook/main.ts @@ -71,7 +71,7 @@ const result = await agentRunner.run({ prompt }); const parseResult = OutputSchema.safeParse(result.finalOutput); if (parseResult.success) { - logger.info(`Result: ${parseResult.data.message}`); + logger.info("Result", { message: parseResult.data.message }); } else { logger.warn("Unexpected response format"); logger.info(String(result.finalOutput)); diff --git a/src/cli/name-explorer/clients/database.ts b/src/cli/name-explorer/clients/database.ts index 84c5a47..aaf6d71 100644 --- a/src/cli/name-explorer/clients/database.ts +++ b/src/cli/name-explorer/clients/database.ts @@ -92,9 +92,11 @@ export class NameDatabase { this.db.exec("ROLLBACK"); throw error; } - this.logger.debug( - `Inserted ${entries.length} ${gender} names for decade ${decade}` - ); + this.logger.debug("Inserted names for decade", { + count: entries.length, + gender, + decade, + }); } /** @@ -152,7 +154,9 @@ export class NameDatabase { this.insertNames(decadeData.decade, "boy", decadeData.boys); this.insertNames(decadeData.decade, "girl", decadeData.girls); } - this.logger.debug(`Loaded ${this.getTotalCount()} records from JSON`); + this.logger.debug("Loaded records from JSON", { + count: this.getTotalCount(), + }); } /** @@ -286,7 +290,7 @@ export class AggregatedNameDatabase { this.db.exec("ROLLBACK"); throw error; } - this.logger.debug(`Loaded ${gender} names from ${filePath}`); + this.logger.debug("Loaded names from file", { gender, filePath }); } /** diff --git a/src/cli/name-explorer/clients/pipeline.ts b/src/cli/name-explorer/clients/pipeline.ts index 5fc6c37..a270b8b 100644 --- a/src/cli/name-explorer/clients/pipeline.ts +++ b/src/cli/name-explorer/clients/pipeline.ts @@ -108,12 +108,12 @@ export class NameSuggesterPipeline { let fromCache = false; if (htmlExists && mdExists && !this.refetch) { - this.logger.debug(`Cached: ${decade} page ${page}`); + this.logger.debug("Cache hit", { decade, page }); html = await fs.readFile(htmlFile, "utf-8"); markdown = await fs.readFile(mdFile, "utf-8"); fromCache = true; } else { - this.logger.info(`Fetching ${decade} page ${page}...`); + this.logger.info("Fetching decade page", { decade, page }); html = await this.fetchClient.fetchHtml(url); markdown = await this.fetchClient.fetchMarkdown(url); @@ -141,14 +141,19 @@ export class NameSuggesterPipeline { pages?: number[]; } = {}): Promise { this.logger.info( - `Will process ${decades.length} decades × ${pages.length} pages = ${decades.length * pages.length} combinations` + "Processing plan", + { + decades: decades.length, + pages: pages.length, + combinations: decades.length * pages.length, + } ); let cachedPages = 0; let fetchedPages = 0; for (const decade of decades) { - this.logger.info(`Processing decade ${decade}...`); + this.logger.info("Processing decade", { decade }); for (const page of pages) { const { parsedNames, fromCache } = await this.fetchDecadePage({ @@ -183,7 +188,7 @@ export class NameSuggesterPipeline { const consolidatedData: ConsolidatedData = this.db.getAll(); const outputPath = path.join(this.outputDir, filename); await fs.writeFile(outputPath, JSON.stringify(consolidatedData, null, 2)); - this.logger.info(`Saved consolidated data to ${outputPath}`); + this.logger.info("Saved consolidated data", { outputPath }); return outputPath; } @@ -200,9 +205,9 @@ export class NameSuggesterPipeline { const jsonContent = await fs.readFile(outputPath, "utf-8"); const data = JSON.parse(jsonContent) as ConsolidatedData; this.db.loadFromConsolidatedData(data); - this.logger.info( - `Loaded existing data from JSON (${this.db.getTotalCount()} records)` - ); + this.logger.info("Loaded existing data from JSON", { + count: this.db.getTotalCount(), + }); const aggregatedDb = await this.loadAggregatedCsvData(); @@ -219,13 +224,15 @@ export class NameSuggesterPipeline { const { totalPages, cachedPages, fetchedPages } = await this.processAllDecades(); - this.logger.info( - `Processing complete: ${fetchedPages} fetched, ${cachedPages} cached, ${totalPages} total` - ); + this.logger.info("Processing complete", { + fetchedPages, + cachedPages, + totalPages, + }); - this.logger.info( - `Database contains ${this.db.getTotalCount()} name records` - ); + this.logger.info("Database contains name records", { + count: this.db.getTotalCount(), + }); await this.saveConsolidatedData(); @@ -267,9 +274,9 @@ export class NameSuggesterPipeline { aggregatedDb.loadFromCsv(femaleCsvPath, "female"); } - this.logger.info( - `Loaded aggregated CSV data (${aggregatedDb.getTotalCount()} records)` - ); + this.logger.info("Loaded aggregated CSV data", { + count: aggregatedDb.getTotalCount(), + }); return aggregatedDb; } diff --git a/src/cli/name-explorer/main.ts b/src/cli/name-explorer/main.ts index e3ce0e9..39f8247 100644 --- a/src/cli/name-explorer/main.ts +++ b/src/cli/name-explorer/main.ts @@ -55,7 +55,7 @@ const runStatsMode = async () => { const outputPath = "tmp/name-explorer/statistics.html"; await writeFile(outputPath, html, "utf-8"); - logger.info(`Statistics page written to ${outputPath}`); + logger.info("Statistics page written", { outputPath }); }; // --- AI Mode: Interactive Q&A with SQL agent --- diff --git a/src/cli/scrape-publications/clients/publication-pipeline.ts b/src/cli/scrape-publications/clients/publication-pipeline.ts index be8959f..58f5b78 100644 --- a/src/cli/scrape-publications/clients/publication-pipeline.ts +++ b/src/cli/scrape-publications/clients/publication-pipeline.ts @@ -197,7 +197,7 @@ export class PublicationPipeline { `content.md (${fromCache.markdown ? "cached" : sourceLabel})`, `content.html (${fromCache.html ? "cached" : sourceLabel})`, ]; - this.logger.info(`Content ready: ${contentStatus.join(", ")}`); + this.logger.info("Content ready", { contentStatus }); return { markdown, html, fromCache, source }; } @@ -226,7 +226,10 @@ export class PublicationPipeline { path.join(this.outputDir, "links.json"), JSON.stringify(allLinks, null, 2) ); - this.logger.info(`Saved ${allLinks.length} links to links.json`); + this.logger.info("Saved links", { + count: allLinks.length, + file: "links.json", + }); let filteredLinks = ( filterSubstring @@ -238,9 +241,10 @@ export class PublicationPipeline { path.join(this.outputDir, "filtered-links.json"), JSON.stringify(filteredLinks, null, 2) ); - this.logger.info( - `Saved ${filteredLinks.length} filtered links to filtered-links.json` - ); + this.logger.info("Saved filtered links", { + count: filteredLinks.length, + file: "filtered-links.json", + }); let filteredUrlSet = new Set(filteredLinks); let linkCandidates = this.scraper.extractLinkCandidates( @@ -296,18 +300,19 @@ export class PublicationPipeline { usedFallback = true; currentSource = "basic-fetch"; - this.logger.info( - `Fallback fetch found ${linkCandidates.length} link candidates` - ); + this.logger.info("Fallback fetch found link candidates", { + count: linkCandidates.length, + }); } await fs.writeFile( path.join(this.outputDir, "link-candidates.json"), JSON.stringify(linkCandidates, null, 2) ); - this.logger.info( - `Saved ${linkCandidates.length} link candidates to link-candidates.json` - ); + this.logger.info("Saved link candidates", { + count: linkCandidates.length, + file: "link-candidates.json", + }); return { allLinks, @@ -337,9 +342,11 @@ export class PublicationPipeline { JSON.stringify(selectors, null, 2) ); - this.logger.info(`Identified selectors:`); - this.logger.info(` Title: ${selectors.titleSelector}`); - this.logger.info(` Date: ${selectors.dateSelector ?? "(not found)"}`); + this.logger.info("Identified selectors"); + this.logger.info("Title selector", { selector: selectors.titleSelector }); + this.logger.info("Date selector", { + selector: selectors.dateSelector ?? "(not found)", + }); this.logger.info("Extracting publication data..."); @@ -353,9 +360,10 @@ export class PublicationPipeline { JSON.stringify(publications, null, 2) ); - this.logger.info( - `Saved ${publications.length} publications to publication-links.json` - ); + this.logger.info("Saved publication links", { + count: publications.length, + file: "publication-links.json", + }); return { selectors, publications }; } @@ -382,7 +390,9 @@ export class PublicationPipeline { titleSlugCounts.set(titleSlug, (titleSlugCounts.get(titleSlug) ?? 0) + 1); } - this.logger.info(`Found ${publications.length} publication links to fetch`); + this.logger.info("Found publication links to fetch", { + count: publications.length, + }); let fetchedCount = 0; let skippedCount = 0; @@ -393,7 +403,7 @@ export class PublicationPipeline { const titleSlug = titleSlugs[index]; if (!titleSlug) { - this.logger.warn(`Skipping publication with empty title slug: ${url}`); + this.logger.warn("Skipping publication with empty title slug", { url }); continue; } @@ -416,9 +426,11 @@ export class PublicationPipeline { if (!needsHtml && !needsMarkdown) { skippedCount++; - this.logger.info( - `[${skippedCount + fetchedCount}/${publications.length}] Cached: ${url}` - ); + this.logger.info("Publication cached", { + index: skippedCount + fetchedCount, + total: publications.length, + url, + }); continue; } @@ -442,20 +454,30 @@ export class PublicationPipeline { needsHtml ? "Fetched HTML" : "Cached HTML", needsMarkdown ? "Wrote Markdown" : "Cached Markdown", ]; - this.logger.info( - `[${skippedCount + fetchedCount}/${publications.length}] ${statusParts.join(", ")}: ${url}` - ); + this.logger.info("Publication processed", { + index: skippedCount + fetchedCount, + total: publications.length, + status: statusParts, + url, + }); } catch (error) { this.logger.error( - `[${skippedCount + fetchedCount}/${publications.length}] Failed: ${url}`, + "Publication fetch failed", + { + index: skippedCount + fetchedCount, + total: publications.length, + url, + }, error ); } } - this.logger.info( - `Fetch complete: ${fetchedCount} new HTML, ${markdownCount} markdown written, ${skippedCount} cached` - ); + this.logger.info("Fetch complete", { + fetchedCount, + markdownCount, + skippedCount, + }); return { fetchedCount, skippedCount, markdownCount }; } @@ -493,13 +515,13 @@ export class PublicationPipeline { const sampleHtmlPath = path.join(publicationsDir, firstHtmlFile); const sampleHtml = await fs.readFile(sampleHtmlPath, "utf-8"); - this.logger.info(`Analyzing sample HTML: ${firstHtmlFile}`); + this.logger.info("Analyzing sample HTML", { file: firstHtmlFile }); const contentSelectors = await this.scraper.identifyContentSelector(sampleHtml); - this.logger.info( - `Identified content selector: ${contentSelectors.contentSelector}` - ); + this.logger.info("Identified content selector", { + selector: contentSelectors.contentSelector, + }); await fs.writeFile( path.join(this.outputDir, "content-selectors.json"), @@ -542,7 +564,9 @@ export class PublicationPipeline { filename: firstPossibleFilename, error: "HTML file not found", }); - this.logger.warn(`HTML file not found for: ${publication.title}`); + this.logger.warn("HTML file not found for publication", { + title: publication.title, + }); continue; } @@ -557,7 +581,9 @@ export class PublicationPipeline { filename: usedFilename, error: "No content found with selector", }); - this.logger.warn(`No content found for: ${publication.title}`); + this.logger.warn("No content found for publication", { + title: publication.title, + }); continue; } @@ -574,9 +600,11 @@ export class PublicationPipeline { filename: usedFilename, }); - this.logger.info( - `[${extractionResults.length}/${publications.length}] Extracted: ${publication.title}` - ); + this.logger.info("Publication extracted", { + index: extractionResults.length, + total: publications.length, + title: publication.title, + }); } await fs.writeFile( @@ -596,9 +624,10 @@ export class PublicationPipeline { JSON.stringify(report, null, 2) ); - this.logger.info( - `Content extraction complete: ${report.successful}/${report.total} publications processed` - ); + this.logger.info("Content extraction complete", { + successful: report.successful, + total: report.total, + }); return { publications: publicationsWithContent, report }; } @@ -621,7 +650,7 @@ export class PublicationPipeline { const reviewPath = path.join(this.outputDir, "review.html"); await fs.writeFile(reviewPath, reviewHtml); - this.logger.info(`Review page saved to: ${reviewPath}`); + this.logger.info("Review page saved", { reviewPath }); return reviewPath; } diff --git a/src/cli/scrape-publications/clients/publication-scraper.ts b/src/cli/scrape-publications/clients/publication-scraper.ts index d2bf715..35c40b4 100644 --- a/src/cli/scrape-publications/clients/publication-scraper.ts +++ b/src/cli/scrape-publications/clients/publication-scraper.ts @@ -242,9 +242,12 @@ IMPORTANT: Respond with ONLY a valid JSON object: if (firstGroup) { const [topSignature, topGroup] = firstGroup; if (topGroup.length > 0) { - this.logger.debug( - `Selected structure group: ${topSignature} (${topGroup.length} candidates, score: ${this.scoreStructureSignature(topSignature)})` - ); + const score = this.scoreStructureSignature(topSignature); + this.logger.debug("Selected structure group", { + signature: topSignature, + candidates: topGroup.length, + score, + }); return topGroup.slice(0, maxSamples); } } @@ -590,7 +593,9 @@ Respond with only a JSON object containing "titleSelector" and "dateSelector" (n const date = this.extractDate(candidate.html, selectors, candidate.url); if (!title) { - this.logger.warn(`Could not extract title for: ${candidate.url}`); + this.logger.warn("Could not extract title for candidate", { + url: candidate.url, + }); continue; } @@ -604,9 +609,10 @@ Respond with only a JSON object containing "titleSelector" and "dateSelector" (n if (result.success) { publications.push(result.data); } else { - this.logger.warn( - `Validation failed for ${candidate.url}: ${result.error.message}` - ); + this.logger.warn("Validation failed for candidate", { + url: candidate.url, + error: result.error.message, + }); } } diff --git a/src/cli/scrape-publications/main.ts b/src/cli/scrape-publications/main.ts index 17f07c4..a1630e0 100644 --- a/src/cli/scrape-publications/main.ts +++ b/src/cli/scrape-publications/main.ts @@ -43,7 +43,7 @@ const outputDir = path.join( urlSlug ); -logger.info(`Output directory: ${outputDir}`); +logger.info("Output directory", { outputDir }); // 3. Create pipeline const pipeline = new PublicationPipeline({ diff --git a/src/clients/agent-runner.ts b/src/clients/agent-runner.ts index e740cf1..ff8350c 100644 --- a/src/clients/agent-runner.ts +++ b/src/clients/agent-runner.ts @@ -88,21 +88,24 @@ export class AgentRunner { if (this.logToolArgs) { const args = String(toolCall.arguments); - this.logger.tool(`Calling ${tool.name}: ${args || "no arguments"}`); + this.logger.tool("Calling tool", { + name: tool.name, + args: args || "no arguments", + }); } else { - this.logger.tool(`Calling ${tool.name}`); + this.logger.tool("Calling tool", { name: tool.name }); } }); this.runner.on("agent_tool_end", (_context, _agent, tool, result) => { - this.logger.tool(`${tool.name} completed`); + this.logger.tool("Tool completed", { name: tool.name }); if (this.logToolResults) { const preview = result.length > this.resultPreviewLimit ? result.substring(0, this.resultPreviewLimit) + "..." : result; - this.logger.debug(`Result: ${preview}`); + this.logger.debug("Tool result preview", { preview }); } }); } diff --git a/src/clients/playwright-scraper.ts b/src/clients/playwright-scraper.ts index b158951..a87db43 100644 --- a/src/clients/playwright-scraper.ts +++ b/src/clients/playwright-scraper.ts @@ -80,8 +80,11 @@ export class PlaywrightScraper { const timeout = options.timeoutMs ?? this.defaultTimeoutMs; const waitStrategy = options.waitStrategy ?? this.defaultWaitStrategy; - this.logger.debug(`Navigating to: ${targetUrl}`); - this.logger.debug(`Wait strategy: ${waitStrategy}, timeout: ${timeout}ms`); + this.logger.debug("Navigating to URL", { targetUrl }); + this.logger.debug("Wait strategy", { + waitStrategy, + timeoutMs: timeout, + }); await page.goto(targetUrl, { timeout, @@ -89,7 +92,9 @@ export class PlaywrightScraper { }); if (options.waitForSelector) { - this.logger.debug(`Waiting for selector: ${options.waitForSelector}`); + this.logger.debug("Waiting for selector", { + selector: options.waitForSelector, + }); await page.waitForSelector(options.waitForSelector, { timeout }); } @@ -112,9 +117,9 @@ export class PlaywrightScraper { const html = await page.content(); const sanitized = sanitizeHtml(html); - this.logger.debug( - `Scraped and sanitized HTML (${sanitized.length} chars)` - ); + this.logger.debug("Scraped and sanitized HTML", { + length: sanitized.length, + }); return sanitized; } catch (error) { this.handleError({ targetUrl, error }); @@ -138,7 +143,7 @@ export class PlaywrightScraper { const html = await this.scrapeHtml({ targetUrl, ...options }); const markdown = convertToMarkdown(html); - this.logger.debug(`Converted to Markdown (${markdown.length} chars)`); + this.logger.debug("Converted to Markdown", { length: markdown.length }); return markdown; } @@ -157,29 +162,35 @@ export class PlaywrightScraper { }): void { if (error instanceof Error) { if (error.name === "TimeoutError" || error.message.includes("Timeout")) { - this.logger.error( - `Timeout while scraping ${targetUrl}: ${error.message}` - ); + this.logger.error("Timeout while scraping", { + targetUrl, + message: error.message, + }); return; } if (error.message.includes("net::ERR_")) { - this.logger.error( - `Network error scraping ${targetUrl}: ${error.message}` - ); + this.logger.error("Network error scraping", { + targetUrl, + message: error.message, + }); return; } if (error.message.includes("Navigation failed")) { - this.logger.error( - `Navigation failed for ${targetUrl}: ${error.message}` - ); + this.logger.error("Navigation failed", { + targetUrl, + message: error.message, + }); return; } - this.logger.error(`Error scraping ${targetUrl}: ${error.message}`); + this.logger.error("Error scraping", { + targetUrl, + message: error.message, + }); } else { - this.logger.error(`Unknown error scraping ${targetUrl}:`, error); + this.logger.error("Unknown error scraping", { targetUrl }, error); } } diff --git a/src/tools/fetch-url/fetch-url-tool.ts b/src/tools/fetch-url/fetch-url-tool.ts index f4774bd..a12c023 100644 --- a/src/tools/fetch-url/fetch-url-tool.ts +++ b/src/tools/fetch-url/fetch-url-tool.ts @@ -423,11 +423,14 @@ export const createFetchUrlTool = ({ logger }: FetchUrlToolOptions) => etag?: string; lastModified?: string; }) => { - logger.tool(`Fetching URL: ${params.url}`); + logger.tool("Fetching URL", { url: params.url }); const result = await executeFetch(params); - logger.tool( - `Fetch result: ok=${result.ok}, status=${result.status}, finalUrl=${result.finalUrl}${result.error ? `, error=${result.error}` : ""}` - ); + logger.tool("Fetch result", { + ok: result.ok, + status: result.status, + finalUrl: result.finalUrl, + error: result.error, + }); return JSON.stringify(result, null, 2); }, }); diff --git a/src/tools/list-files/list-files-tool.ts b/src/tools/list-files/list-files-tool.ts index 0dc2a5d..e11bddf 100644 --- a/src/tools/list-files/list-files-tool.ts +++ b/src/tools/list-files/list-files-tool.ts @@ -26,7 +26,7 @@ export const createListFilesTool = ({ logger }: ListFilesToolOptions) => additionalProperties: false, }, execute: async ({ path: dirPath }: { path?: string }) => { - logger.tool(`Listing files: ${dirPath ?? "tmp root"}`); + logger.tool("Listing files", { path: dirPath ?? "tmp root" }); const targetPath = await resolveTmpPathForList(dirPath); const entries = await fs.readdir(targetPath, { withFileTypes: true }); @@ -37,7 +37,10 @@ export const createListFilesTool = ({ logger }: ListFilesToolOptions) => const relativePath = path.relative(TMP_ROOT, targetPath); const displayPath = relativePath || "tmp"; - logger.tool(`Listed ${entries.length} entries in ${displayPath}`); + logger.tool("Listed entries", { + count: entries.length, + displayPath, + }); return lines.length > 0 ? `Contents of ${displayPath}:\n${lines.join("\n")}` : `${displayPath} is empty`; diff --git a/src/tools/read-file/read-file-tool.ts b/src/tools/read-file/read-file-tool.ts index 3da6a70..f122d48 100644 --- a/src/tools/read-file/read-file-tool.ts +++ b/src/tools/read-file/read-file-tool.ts @@ -24,9 +24,9 @@ export const createReadFileTool = ({ logger }: ReadFileToolOptions) => additionalProperties: false, }, execute: async ({ path: filePath }: { path: string }) => { - logger.tool(`Reading file: ${filePath}`); + logger.tool("Reading file", { path: filePath }); const targetPath = await resolveTmpPathForRead(filePath); - logger.tool(`Read file result: ${targetPath}`); + logger.tool("Read file result", { targetPath }); return fs.readFile(targetPath, "utf8"); }, }); diff --git a/src/tools/run-python/run-python-tool.ts b/src/tools/run-python/run-python-tool.ts index 8010a9f..96d0be8 100644 --- a/src/tools/run-python/run-python-tool.ts +++ b/src/tools/run-python/run-python-tool.ts @@ -242,7 +242,7 @@ export const createRunPythonTool = ({ additionalProperties: false, }, execute: async (params: { scriptName: string; input: string }) => { - logger.tool(`Running Python script: ${params.scriptName}`); + logger.tool("Running Python script", { scriptName: params.scriptName }); // Parse the input string to object if provided (empty string means no input) let parsedInput: Record | undefined; @@ -267,9 +267,12 @@ export const createRunPythonTool = ({ input: parsedInput, pythonBinary, }); - logger.tool( - `Python result: success=${result.success}, exitCode=${String(result.exitCode)}, durationMs=${result.durationMs}${result.error ? `, error=${result.error}` : ""}` - ); + logger.tool("Python result", { + success: result.success, + exitCode: result.exitCode, + durationMs: result.durationMs, + error: result.error, + }); return JSON.stringify(result, null, 2); }, }); diff --git a/src/tools/write-file/write-file-tool.ts b/src/tools/write-file/write-file-tool.ts index e5cbdc1..ade755b 100644 --- a/src/tools/write-file/write-file-tool.ts +++ b/src/tools/write-file/write-file-tool.ts @@ -32,12 +32,13 @@ export const createWriteFileTool = ({ logger }: WriteFileToolOptions) => path: string; content: string; }) => { - logger.tool(`Writing file: ${filePath}`); + logger.tool("Writing file", { path: filePath }); const targetPath = await resolveTmpPathForWrite(filePath); await fs.writeFile(targetPath, content, "utf8"); const relativePath = path.relative(TMP_ROOT, targetPath); + const displayPath = path.join("tmp", relativePath); const bytes = Buffer.byteLength(content, "utf8"); - logger.tool(`Wrote ${bytes} bytes to tmp/${relativePath}`); - return `Wrote ${bytes} bytes to tmp/${relativePath}`; + logger.tool("Wrote file", { bytes, path: displayPath }); + return `Wrote ${bytes} bytes to ${displayPath}`; }, }); diff --git a/src/utils/parse-args.ts b/src/utils/parse-args.ts index d26f588..f97c2c6 100644 --- a/src/utils/parse-args.ts +++ b/src/utils/parse-args.ts @@ -19,6 +19,6 @@ export const parseArgs = ({ }: ParseArgsOptions): z.infer => { logger.debug("Parsing CLI arguments..."); const args = schema.parse(argv); - logger.debug(`Parsed args: ${JSON.stringify(args)}`); + logger.debug("Parsed args", { args }); return args; }; diff --git a/src/utils/question-handler.ts b/src/utils/question-handler.ts index 745f785..973e51c 100644 --- a/src/utils/question-handler.ts +++ b/src/utils/question-handler.ts @@ -100,12 +100,12 @@ export class QuestionHandler { const validationMessage = errorMessage ?? result.error.issues[0]?.message ?? "Invalid input"; - this.logger.question(`Validation failed: ${validationMessage}`); + this.logger.question("Validation failed", { message: validationMessage }); if (attempts < maxRetries) { - this.logger.question( - `Please try again (${maxRetries - attempts} attempts remaining)` - ); + this.logger.question("Please try again", { + remainingAttempts: maxRetries - attempts, + }); } } From d307a4e91e6a04dc71d7c4d40bb0643b554a4831 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:26:49 +0200 Subject: [PATCH 09/19] feat: implement ETF data fetching with caching and logging - Add EtfDataFetcher class for fetching and caching ETF data - Update main logic to integrate ETF data fetching - Enhance CLI argument parsing to include ISIN and refresh options --- .../etf-backtest/clients/etf-data-fetcher.ts | 160 ++++++++++++++++++ src/cli/etf-backtest/constants.ts | 20 +++ src/cli/etf-backtest/main.ts | 24 ++- src/cli/etf-backtest/schemas.ts | 9 + .../etf-backtest/scripts/run_experiment.py | 14 +- src/cli/etf-backtest/types/etf-data.ts | 32 ++++ src/clients/playwright-scraper.ts | 134 +++++++++++++++ 7 files changed, 379 insertions(+), 14 deletions(-) create mode 100644 src/cli/etf-backtest/clients/etf-data-fetcher.ts create mode 100644 src/cli/etf-backtest/types/etf-data.ts diff --git a/src/cli/etf-backtest/clients/etf-data-fetcher.ts b/src/cli/etf-backtest/clients/etf-data-fetcher.ts new file mode 100644 index 0000000..8d0180f --- /dev/null +++ b/src/cli/etf-backtest/clients/etf-data-fetcher.ts @@ -0,0 +1,160 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { Logger } from "~clients/logger"; +import { PlaywrightScraper } from "~clients/playwright-scraper"; +import { + resolveTmpPathForRead, + resolveTmpPathForWrite, +} from "~tools/utils/fs"; + +import { + API_CAPTURE_TIMEOUT_MS, + ETF_CHART_PERIOD_KEY, + ETF_CHART_PERIOD_VALUE, + ETF_DATA_DIR, + ETF_DATA_FILENAME, + ETF_PROFILE_PATH, + getEtfApiPattern, + JUST_ETF_BASE_URL, +} from "../constants"; +import type { EtfDataResponse } from "../types/etf-data"; +import { EtfDataResponseSchema, isEtfDataResponse } from "../types/etf-data"; + +export type EtfDataFetcherConfig = { + logger: Logger; + headless?: boolean; +}; + +export type FetchResult = { + data: EtfDataResponse; + dataPath: string; + fromCache: boolean; +}; + +/** + * Fetches ETF data from justetf.com with caching. + * Data is stored in ISIN-specific folders under tmp/etf-backtest/{isin}/data.json. + */ +export class EtfDataFetcher { + private logger: Logger; + private scraper: PlaywrightScraper; + + constructor(config: EtfDataFetcherConfig) { + this.logger = config.logger; + this.scraper = new PlaywrightScraper({ + logger: config.logger, + headless: config.headless ?? true, + defaultWaitStrategy: "domcontentloaded", + }); + } + + /** + * Build the relative path for cached data (relative to tmp/). + */ + private getDataPath(isin: string): string { + return path.join(ETF_DATA_DIR, isin, ETF_DATA_FILENAME); + } + + /** + * Build the justetf.com profile URL for the given ISIN. + */ + private buildProfileUrl(isin: string): string { + return `${JUST_ETF_BASE_URL}${ETF_PROFILE_PATH}?isin=${encodeURIComponent(isin)}`; + } + + /** + * Check if cached data exists for the given ISIN. + */ + private async hasCachedData(isin: string): Promise { + try { + await resolveTmpPathForRead(this.getDataPath(isin)); + return true; + } catch { + return false; + } + } + + /** + * Load cached data from disk. + */ + private async loadCachedData(isin: string): Promise { + const dataPath = await resolveTmpPathForRead(this.getDataPath(isin)); + const content = await fs.readFile(dataPath, "utf8"); + const json = JSON.parse(content) as unknown; + return EtfDataResponseSchema.parse(json); + } + + /** + * Save data to disk. + */ + private async saveData(isin: string, data: EtfDataResponse): Promise { + const dataPath = await resolveTmpPathForWrite(this.getDataPath(isin)); + await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf8"); + return dataPath; + } + + /** + * Fetch ETF data from justetf.com by navigating to the profile page + * and intercepting the API response. + */ + private async fetchFromWeb(isin: string): Promise { + const profileUrl = this.buildProfileUrl(isin); + this.logger.info("Fetching ETF data from justetf.com", { + isin, + url: profileUrl, + }); + + const result = await this.scraper.scrapeWithNetworkCapture({ + targetUrl: profileUrl, + captureUrlPattern: getEtfApiPattern(isin), + captureTimeoutMs: API_CAPTURE_TIMEOUT_MS, + validateResponse: isEtfDataResponse, + localStorage: { + [ETF_CHART_PERIOD_KEY]: ETF_CHART_PERIOD_VALUE, + }, + }); + + const validated = EtfDataResponseSchema.parse(result.data); + + this.logger.info("Successfully fetched ETF data", { + isin, + seriesLength: validated.series.length, + latestDate: validated.latestQuoteDate, + capturedUrl: result.capturedUrl, + }); + + return validated; + } + + /** + * Fetch ETF data with caching support. + * Returns cached data if available, unless refresh is true. + */ + async fetch(isin: string, refresh: boolean): Promise { + const relativePath = this.getDataPath(isin); + + // Check cache unless refresh is requested + if (!refresh && (await this.hasCachedData(isin))) { + this.logger.info("Using cached ETF data", { isin }); + const data = await this.loadCachedData(isin); + const dataPath = await resolveTmpPathForRead(relativePath); + return { data, dataPath, fromCache: true }; + } + + // Fetch from web + const data = await this.fetchFromWeb(isin); + const dataPath = await this.saveData(isin, data); + + this.logger.info("Saved ETF data to cache", { isin, path: dataPath }); + + return { data, dataPath, fromCache: false }; + } + + /** + * Close the browser and release resources. + */ + async close(): Promise { + await this.scraper.close(); + } +} diff --git a/src/cli/etf-backtest/constants.ts b/src/cli/etf-backtest/constants.ts index b89641a..7e3d0ee 100644 --- a/src/cli/etf-backtest/constants.ts +++ b/src/cli/etf-backtest/constants.ts @@ -4,6 +4,26 @@ export const DEFAULT_VERBOSE = false; export const DEFAULT_TICKER = "SPY"; export const DEFAULT_MAX_ITERATIONS = 5; export const DEFAULT_SEED = 42; +export const DEFAULT_REFRESH = false; + +// Default ISIN: iShares Core S&P 500 UCITS ETF +export const DEFAULT_ISIN = "IE00B5BMR087"; + +// justetf.com configuration +export const JUST_ETF_BASE_URL = "https://www.justetf.com"; +export const ETF_PROFILE_PATH = "/en/etf-profile.html"; +// Match performance-chart requests with full historical data (dateFrom before 2020) +export const getEtfApiPattern = (isin: string): RegExp => + new RegExp(`/api/etfs/${isin}/performance-chart.*dateFrom=(19|200|201)`); +export const API_CAPTURE_TIMEOUT_MS = 15000; + +// localStorage key to set chart period to MAX for full historical data +export const ETF_CHART_PERIOD_KEY = "etfProfileChart.defaultPeriod"; +export const ETF_CHART_PERIOD_VALUE = "MAX"; + +// Data storage paths (relative to tmp/) +export const ETF_DATA_DIR = "etf-backtest"; +export const ETF_DATA_FILENAME = "data.json"; export const MAX_NO_IMPROVEMENT = 2; export const ZERO = 0; diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index ce34557..758dd20 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -10,6 +10,7 @@ import { Logger } from "~clients/logger"; import { createRunPythonTool } from "~tools/run-python/run-python-tool"; import { parseArgs } from "~utils/parse-args"; +import { EtfDataFetcher } from "./clients/etf-data-fetcher"; import { DECIMAL_PLACES, FEATURE_MENU, @@ -39,7 +40,7 @@ import { computeScore } from "./utils/scoring"; const logger = new Logger(); // --- Parse CLI arguments --- -const { verbose, ticker, maxIterations, seed } = parseArgs({ +const { verbose, ticker, isin, refresh, maxIterations, seed } = parseArgs({ logger, schema: CliArgsSchema, }); @@ -100,7 +101,7 @@ IMPORTANT: Run exactly ONE experiment per turn. Do not run multiple experiments. Call runPython with: - scriptName: "run_experiment.py" -- input: { "ticker": "", "featureIds": [...], "seed": } +- input: { "ticker": "", "featureIds": [...], "seed": , "dataPath": "" } After you receive results, respond with your analysis. Do not call runPython again in the same turn. @@ -115,7 +116,7 @@ After each experiment, respond with JSON (do not call any more tools): `; // --- Run iterative optimization --- -const runAgentOptimization = async () => { +const runAgentOptimization = async (dataPath: string) => { const runPythonTool = createRunPythonTool({ scriptsDir: SCRIPTS_DIR, logger, @@ -129,6 +130,7 @@ const runAgentOptimization = async () => { instructions: buildInstructions(), logger, logToolResults: verbose, + stateless: true, // Required for reasoning models to avoid "reasoning item without following item" errors }); // Track state @@ -141,21 +143,21 @@ const runAgentOptimization = async () => { // Initial prompt let currentPrompt = ` -Start feature selection optimization for ${ticker}. +Start feature selection optimization for ${ticker} (ISIN: ${isin}). Begin by selecting ${MIN_FEATURES}-${MAX_FEATURES} features that you think will best predict ${PREDICTION_HORIZON_MONTHS}-month returns. Consider using a mix from each category (momentum, trend, risk). Use runPython with: - scriptName: "run_experiment.py" -- input: { "ticker": "${ticker}", "featureIds": [...your features...], "seed": ${seed} } +- input: { "ticker": "${ticker}", "featureIds": [...your features...], "seed": ${seed}, "dataPath": "${dataPath}" } After running the experiment, analyze the results and decide whether to continue or stop. `; while (iteration < maxIterations) { iteration++; - logger.info("\n--- Iteration ---", { iteration, maxIterations }); + logger.info("--- Iteration ---", { iteration, maxIterations }); let runResult; try { @@ -289,11 +291,17 @@ Backtest metrics (Sharpe, drawdown) are informational only. }; // --- Main --- -logger.info("ETF Backtest Feature Optimization starting..."); +logger.info("ETF Backtest Feature Optimization starting...", { isin }); if (verbose) { logger.debug("Verbose mode enabled"); } -await runAgentOptimization(); +const fetcher = new EtfDataFetcher({ logger }); +try { + const { dataPath } = await fetcher.fetch(isin, refresh); + await runAgentOptimization(dataPath); +} finally { + await fetcher.close(); +} logger.info("\nETF Backtest completed."); diff --git a/src/cli/etf-backtest/schemas.ts b/src/cli/etf-backtest/schemas.ts index 2878b51..129dbb9 100644 --- a/src/cli/etf-backtest/schemas.ts +++ b/src/cli/etf-backtest/schemas.ts @@ -1,15 +1,24 @@ import { z } from "zod"; import { + DEFAULT_ISIN, DEFAULT_MAX_ITERATIONS, + DEFAULT_REFRESH, DEFAULT_SEED, DEFAULT_TICKER, DEFAULT_VERBOSE, } from "./constants"; +// ISIN validation: 2 letter country code + 10 alphanumeric characters +const IsinSchema = z + .string() + .regex(/^[A-Z]{2}[A-Z0-9]{10}$/, "Invalid ISIN format"); + export const CliArgsSchema = z.object({ verbose: z.coerce.boolean().default(DEFAULT_VERBOSE), ticker: z.string().default(DEFAULT_TICKER), + isin: IsinSchema.default(DEFAULT_ISIN), + refresh: z.coerce.boolean().default(DEFAULT_REFRESH), maxIterations: z.coerce.number().default(DEFAULT_MAX_ITERATIONS), seed: z.coerce.number().default(DEFAULT_SEED), }); diff --git a/src/cli/etf-backtest/scripts/run_experiment.py b/src/cli/etf-backtest/scripts/run_experiment.py index acca7d1..ff251aa 100644 --- a/src/cli/etf-backtest/scripts/run_experiment.py +++ b/src/cli/etf-backtest/scripts/run_experiment.py @@ -38,7 +38,7 @@ ) # === CONFIG === -DATA_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "data.json" +DEFAULT_DATA_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "data.json" COST_BPS = 5 # transaction cost in basis points @@ -250,15 +250,15 @@ def compute_uncertainty_adjusted( } -def run_experiment(ticker: str, feature_ids: list[str], seed: int) -> dict: +def run_experiment(ticker: str, feature_ids: list[str], seed: int, data_path: Path) -> dict: """Run a single experiment with given features.""" set_seed(seed) # Load data - if not DATA_PATH.exists(): - raise FileNotFoundError(f"Data file not found: {DATA_PATH}") + if not data_path.exists(): + raise FileNotFoundError(f"Data file not found: {data_path}") - df_raw = load_data(DATA_PATH) + df_raw = load_data(data_path) # Build features and add forward target df = build_selected_features(df_raw, feature_ids) @@ -353,6 +353,8 @@ def main(): if feature_ids is None: feature_ids = input_data.get("feature_ids", []) seed = input_data.get("seed", 42) + data_path_str = input_data.get("dataPath") + data_path = Path(data_path_str) if data_path_str else DEFAULT_DATA_PATH # Validate featureIds if not feature_ids: @@ -368,7 +370,7 @@ def main(): sys.exit(1) try: - result = run_experiment(ticker, feature_ids, seed) + result = run_experiment(ticker, feature_ids, seed, data_path) print(json.dumps(result, indent=2), file=sys.stdout) except Exception as e: print(json.dumps({"error": str(e)}), file=sys.stdout) diff --git a/src/cli/etf-backtest/types/etf-data.ts b/src/cli/etf-backtest/types/etf-data.ts new file mode 100644 index 0000000..a2a7876 --- /dev/null +++ b/src/cli/etf-backtest/types/etf-data.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +// Value with raw number and localized string representation +export const LocalizedValueSchema = z.object({ + raw: z.number(), + localized: z.string(), +}); + +// Single data point in the time series +export const SeriesPointSchema = z.object({ + date: z.string(), // ISO format: "YYYY-MM-DD" + value: LocalizedValueSchema, +}); + +// Full API response from justetf.com +export const EtfDataResponseSchema = z.object({ + latestQuote: LocalizedValueSchema, + latestQuoteDate: z.string(), + price: LocalizedValueSchema, + performance: LocalizedValueSchema, + prevDaySeries: z.array(SeriesPointSchema), + series: z.array(SeriesPointSchema), +}); + +export type LocalizedValue = z.infer; +export type SeriesPoint = z.infer; +export type EtfDataResponse = z.infer; + +/** Type guard to check if data matches the expected ETF response shape */ +export const isEtfDataResponse = (data: unknown): data is EtfDataResponse => { + return EtfDataResponseSchema.safeParse(data).success; +}; diff --git a/src/clients/playwright-scraper.ts b/src/clients/playwright-scraper.ts index a87db43..b5d9197 100644 --- a/src/clients/playwright-scraper.ts +++ b/src/clients/playwright-scraper.ts @@ -25,6 +25,23 @@ export type ScrapeRequest = { targetUrl: string; } & ScrapeOptions; +// Options for network capture during scraping +export type NetworkCaptureOptions = ScrapeOptions & { + captureUrlPattern: RegExp; // Pattern to match API requests to capture + captureTimeoutMs?: number; // Timeout waiting for API response (default: 15000) + validateResponse?: (data: unknown) => data is T; // Optional validator to filter responses + localStorage?: Record; // Key-value pairs to set in localStorage before navigation +}; + +export type NetworkCaptureRequest = { + targetUrl: string; +} & NetworkCaptureOptions; + +export type NetworkCaptureResult = { + data: T; + capturedUrl: string; +}; + /** * A web scraper client that uses Playwright to scrape webpages * requiring JavaScript rendering. Returns sanitized HTML or Markdown. @@ -194,6 +211,123 @@ export class PlaywrightScraper { } } + /** + * Scrape a URL while capturing a specific network response. + * Sets up route interception to capture JSON responses matching the URL pattern. + * If validateResponse is provided, only responses passing validation are captured. + */ + async scrapeWithNetworkCapture({ + targetUrl, + captureUrlPattern, + captureTimeoutMs = 15000, + validateResponse, + localStorage, + ...options + }: NetworkCaptureRequest): Promise> { + const browser = await this.getBrowser(); + const page = await browser.newPage(); + + // Set localStorage before any navigation if provided + if (localStorage && Object.keys(localStorage).length > 0) { + const entries = Object.entries(localStorage); + this.logger.debug("Setting localStorage entries", { + keys: entries.map(([k]) => k), + }); + + // Add init script that runs before page load to set localStorage + await page.addInitScript((items: [string, string][]) => { + for (const [key, value] of items) { + window.localStorage.setItem(key, value); + } + }, entries); + } + + let resolveCapture: (result: NetworkCaptureResult) => void; + let rejectCapture: (error: Error) => void; + let captured = false; + + const capturePromise = new Promise>( + (resolve, reject) => { + resolveCapture = resolve; + rejectCapture = reject; + } + ); + + const captureTimeout = setTimeout(() => { + rejectCapture( + new Error( + `Network capture timeout: No response matching ${captureUrlPattern.source} within ${captureTimeoutMs}ms` + ) + ); + }, captureTimeoutMs); + + try { + await page.route("**/*", async (route) => { + // Skip route handling if page is closing or already captured + if (page.isClosed()) { + return; + } + + const request = route.request(); + const url = request.url(); + + if (captureUrlPattern.test(url) && !captured) { + this.logger.debug("Intercepted matching request", { url }); + + try { + const response = await route.fetch(); + const body = await response.text(); + const data = JSON.parse(body) as unknown; + + // If validator provided, check if response matches expected shape + if (validateResponse && !validateResponse(data)) { + this.logger.debug("Response did not pass validation, skipping", { url }); + await route.fulfill({ response }); + return; + } + + this.logger.debug("Captured network response", { + url, + bodyLength: body.length, + }); + + captured = true; + clearTimeout(captureTimeout); + + // Fulfill the route before resolving to avoid race condition + await route.fulfill({ response }); + resolveCapture({ data: data as T, capturedUrl: url }); + } catch (err) { + // Only continue if not already handled and page is still open + if (!page.isClosed()) { + this.logger.warn("Failed to capture response", { url, error: err }); + try { + await route.continue(); + } catch { + // Route may already be handled, ignore + } + } + } + } else { + try { + await route.continue(); + } catch { + // Route may already be handled or page closed, ignore + } + } + }); + + await this.navigateAndWait({ page, targetUrl, options }); + return await capturePromise; + } catch (error) { + clearTimeout(captureTimeout); + this.handleError({ targetUrl, error }); + throw error; + } finally { + await page.close(); + } + } + /** * Close the browser and release resources. * MUST be called when done scraping. From cbcf4e284e9390c6c3c31baeeaf3c4347aae207f Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:26:58 +0200 Subject: [PATCH 10/19] chore: remove legacy backtest and prediction scripts --- src/cli/etf-backtest/README.md | 2 - src/cli/etf-backtest/scripts/backtest.py | 171 ------------------- src/cli/etf-backtest/scripts/predict.py | 206 ----------------------- 3 files changed, 379 deletions(-) delete mode 100644 src/cli/etf-backtest/scripts/backtest.py delete mode 100644 src/cli/etf-backtest/scripts/predict.py diff --git a/src/cli/etf-backtest/README.md b/src/cli/etf-backtest/README.md index c0da63a..c797a7d 100644 --- a/src/cli/etf-backtest/README.md +++ b/src/cli/etf-backtest/README.md @@ -124,8 +124,6 @@ Past performance does not guarantee future results. | ------------------- | ------------------------------------------------- | | `run_experiment.py` | Unified experiment runner (backtest + prediction) | | `shared.py` | Feature registry and model training utilities | -| `backtest.py` | Legacy: standalone backtest | -| `predict.py` | Legacy: standalone prediction | ## Uncertainty Estimation diff --git a/src/cli/etf-backtest/scripts/backtest.py b/src/cli/etf-backtest/scripts/backtest.py deleted file mode 100644 index 125581f..0000000 --- a/src/cli/etf-backtest/scripts/backtest.py +++ /dev/null @@ -1,171 +0,0 @@ -#!/usr/bin/env python3 -""" -Minimal Neural Network ETF Backtest - -A self-contained backtest using a PyTorch MLP to predict next-day returns. -Designed to be readable and avoid common pitfalls: - 1. Lookahead bias: signal at t -> position at t+1 - 2. Data leakage: standardize using train stats only - 3. Time series shuffling: chronological split, no random shuffle -""" - -import numpy as np -import pandas as pd -import torch -from pathlib import Path - -from shared import ( - load_data, - build_base_features, - get_feature_cols, - split_data, - standardize, - train_model, -) - -# === CONFIG === -DATA_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "data.json" -COST_BPS = 5 # transaction cost in basis points - - -# === FEATURE ENGINEERING === -def build_features(df: pd.DataFrame) -> pd.DataFrame: - """Build feature matrix with next-day return as target.""" - df = build_base_features(df) - - # Label: next-day return (shift -1) - df["target"] = df["ret"].shift(-1) - - # Drop rows with NaN - df = df.dropna().reset_index(drop=True) - - return df - - -# === BACKTEST === -def backtest(test_df: pd.DataFrame, predictions: np.ndarray) -> pd.DataFrame: - """ - Run backtest with 1-day lag and transaction costs. - Signal at t -> position at t+1 (avoids lookahead). - """ - df = test_df.copy() - df["pred"] = predictions - - # Signal: pred > 0 -> want to be long - df["signal"] = (df["pred"] > 0).astype(int) - - # Position: apply signal with 1-day lag (position at t+1) - df["position"] = df["signal"].shift(1).fillna(0) - - # Strategy returns - df["strat_ret"] = df["position"] * df["target"] - - # Transaction costs: cost when position changes - df["trade"] = df["position"].diff().abs().fillna(0) - df["cost"] = df["trade"] * (COST_BPS / 10000) - df["strat_ret_net"] = df["strat_ret"] - df["cost"] - - # Equity curve (growth of $1) - df["equity"] = (1 + df["strat_ret_net"]).cumprod() - - return df - - -# === METRICS === -def compute_metrics(df: pd.DataFrame) -> dict: - """Compute backtest performance metrics.""" - returns = df["strat_ret_net"].values - equity = df["equity"].values - - # Total return - total_return = equity[-1] / equity[0] - 1 - - # CAGR (252 trading days) - n_days = len(returns) - years = n_days / 252 - cagr = (equity[-1] ** (1 / years)) - 1 if years > 0 else 0 - - # Annualized volatility - ann_vol = returns.std() * np.sqrt(252) - - # Sharpe ratio (risk-free = 0) - sharpe = (returns.mean() * 252) / ann_vol if ann_vol > 0 else 0 - - # Max drawdown - peak = np.maximum.accumulate(equity) - drawdown = (equity - peak) / peak - max_dd = drawdown.min() - - # Calmar ratio - calmar = cagr / abs(max_dd) if max_dd != 0 else 0 - - return { - "total_return": total_return, - "cagr": cagr, - "ann_volatility": ann_vol, - "sharpe": sharpe, - "max_drawdown": max_dd, - "calmar": calmar, - } - - -def print_metrics(metrics: dict): - """Print metrics in a readable format.""" - print("\n" + "=" * 40) - print("BACKTEST RESULTS") - print("=" * 40) - print(f"Total Return: {metrics['total_return']:>10.2%}") - print(f"CAGR: {metrics['cagr']:>10.2%}") - print(f"Ann. Volatility: {metrics['ann_volatility']:>10.2%}") - print(f"Sharpe Ratio: {metrics['sharpe']:>10.2f}") - print(f"Max Drawdown: {metrics['max_drawdown']:>10.2%}") - print(f"Calmar Ratio: {metrics['calmar']:>10.2f}") - print("=" * 40) - - -# === MAIN === -def main(): - print("Loading data...") - df = load_data(DATA_PATH) - print(f" Loaded {len(df)} rows ({df['date'].min()} to {df['date'].max()})") - - print("Building features...") - df = build_features(df) - print(f" Features built, {len(df)} rows after dropping NaN") - - print("Splitting data...") - train, val, test = split_data(df) - print(f" Train: {len(train)}, Val: {len(val)}, Test: {len(test)}") - - print("Standardizing features...") - feature_cols = get_feature_cols() - X_train, X_val, X_test, y_train, y_val, y_test, _, _ = standardize( - train, val, test, feature_cols - ) - print(f" {len(feature_cols)} features: {feature_cols[:3]}...") - - print("Training model...") - model = train_model(X_train, y_train, X_val, y_val) - - print("Generating predictions on test set...") - device = next(model.parameters()).device - X_test_t = torch.tensor(X_test, dtype=torch.float32, device=device) - model.eval() - with torch.no_grad(): - predictions = model(X_test_t).cpu().numpy() - - print("Running backtest...") - results = backtest(test, predictions) - - metrics = compute_metrics(results) - print_metrics(metrics) - - # Print pitfall avoidance notes - print("\nPITFALL AVOIDANCE:") - print(" 1. Lookahead: signal at t -> position at t+1") - print(" 2. Leakage: standardized with train mean/std only") - print(" 3. No shuffle: chronological train/val/test split") - - -if __name__ == "__main__": - main() diff --git a/src/cli/etf-backtest/scripts/predict.py b/src/cli/etf-backtest/scripts/predict.py deleted file mode 100644 index 022dbb3..0000000 --- a/src/cli/etf-backtest/scripts/predict.py +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env python3 -""" -12-Month ETF Return Prediction - -Predicts cumulative returns for the next ~252 trading days (12 months) -from the most recent data point using a PyTorch MLP. - -Outputs prediction with confidence intervals to tmp/etf-backtest/prediction.json. -""" - -import json -import numpy as np -import pandas as pd -import torch -from pathlib import Path -from datetime import datetime - -from shared import ( - load_data, - build_base_features, - get_feature_cols, - split_data, - standardize, - train_model, -) - -# === CONFIG === -DATA_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "data.json" -OUTPUT_PATH = Path(__file__).parent.parent.parent.parent.parent / "tmp" / "etf-backtest" / "prediction.json" - -FORWARD_DAYS = 252 # ~12 months of trading days - - -# === FEATURE ENGINEERING === -def build_prediction_features(df: pd.DataFrame) -> pd.DataFrame: - """Build feature matrix with 252-day forward return as target.""" - df = build_base_features(df) - - # Target: cumulative return over next 252 trading days - df["target"] = df["price"].shift(-FORWARD_DAYS) / df["price"] - 1 - - # Drop rows with NaN (keeps only rows where we have forward return data) - df = df.dropna().reset_index(drop=True) - - return df - - -def get_latest_features(df_raw: pd.DataFrame, feature_cols: list[str]) -> tuple[np.ndarray, str]: - """ - Get features for the most recent data point (for forward prediction). - Uses raw data with base features, not the training df (which excludes recent rows). - """ - df = build_base_features(df_raw) - df = df.dropna(subset=feature_cols) - - if len(df) == 0: - raise ValueError("No valid feature rows after processing") - - latest = df.iloc[-1] - latest_date = latest["date"].strftime("%Y-%m-%d") - features = latest[feature_cols].values.astype(np.float64) - - return features, latest_date - - -def estimate_uncertainty(model, X_test: np.ndarray, y_test: np.ndarray) -> float: - """ - Estimate prediction uncertainty using test set residuals. - Returns standard deviation of prediction errors for confidence intervals. - """ - device = next(model.parameters()).device - X_test_t = torch.tensor(X_test, dtype=torch.float32, device=device) - - model.eval() - with torch.no_grad(): - preds = model(X_test_t).cpu().numpy() - - residuals = y_test - preds - return float(residuals.std()) - - -def compute_test_metrics(y_test: np.ndarray, predictions: np.ndarray) -> dict: - """Compute R² and MSE on test set.""" - mse = float(np.mean((y_test - predictions) ** 2)) - ss_res = np.sum((y_test - predictions) ** 2) - ss_tot = np.sum((y_test - y_test.mean()) ** 2) - r2 = float(1 - ss_res / ss_tot) if ss_tot > 0 else 0.0 - return {"mse": mse, "r2": r2} - - -def print_prediction(result: dict): - """Print prediction in a readable format.""" - print("\n" + "=" * 50) - print("12-MONTH RETURN PREDICTION") - print("=" * 50) - print(f"Prediction Date: {result['prediction_date']}") - print(f"Horizon: {result['horizon_days']} trading days (~12 months)") - print() - print(f"Predicted Return: {result['predicted_return_pct']:>+.1f}%") - ci = result["confidence_interval_95"] - print(f"95% Confidence: {ci['low']:>+.1f}% to {ci['high']:>+.1f}%") - print() - print("Model Quality:") - model_info = result["model_info"] - print(f" Test R²: {model_info['test_r2']:.3f}") - print(f" Test MSE: {model_info['test_mse']:.6f}") - print(f" Training samples: {model_info['train_samples']}") - print() - print("IMPORTANT CAVEATS:") - for caveat in result["caveats"]: - print(f" - {caveat}") - print("=" * 50) - - -# === MAIN === -def main(): - print("Loading data...") - df_raw = load_data(DATA_PATH) - print(f" Loaded {len(df_raw)} rows ({df_raw['date'].min()} to {df_raw['date'].max()})") - - print("Building features with 252-day forward target...") - df = build_prediction_features(df_raw) - print(f" Features built, {len(df)} rows with valid forward returns") - - if len(df) < 100: - print(f"WARNING: Only {len(df)} training samples. Need more historical data for reliable predictions.") - - print("Splitting data...") - train, val, test = split_data(df) - print(f" Train: {len(train)}, Val: {len(val)}, Test: {len(test)}") - - print("Standardizing features...") - feature_cols = get_feature_cols() - X_train, X_val, X_test, y_train, y_val, y_test, mean, std = standardize( - train, val, test, feature_cols - ) - print(f" {len(feature_cols)} features: {feature_cols[:3]}...") - - print("Training model...") - model = train_model(X_train, y_train, X_val, y_val) - - print("Evaluating on test set...") - device = next(model.parameters()).device - X_test_t = torch.tensor(X_test, dtype=torch.float32, device=device) - model.eval() - with torch.no_grad(): - test_predictions = model(X_test_t).cpu().numpy() - - test_metrics = compute_test_metrics(y_test, test_predictions) - print(f" Test R²: {test_metrics['r2']:.3f}, MSE: {test_metrics['mse']:.6f}") - - print("Estimating prediction uncertainty...") - pred_std = estimate_uncertainty(model, X_test, y_test) - print(f" Prediction std: {pred_std:.4f} ({pred_std*100:.1f}%)") - - print("Getting latest features for forward prediction...") - latest_features, latest_date = get_latest_features(df_raw, feature_cols) - print(f" Latest date: {latest_date}") - - # Standardize latest features using training statistics - latest_features_std = (latest_features - mean) / std - latest_features_t = torch.tensor(latest_features_std, dtype=torch.float32, device=device) - - print("Generating 12-month forward prediction...") - model.eval() - with torch.no_grad(): - prediction = model(latest_features_t).item() - - # Build result - result = { - "prediction_date": latest_date, - "horizon_days": FORWARD_DAYS, - "predicted_return_pct": round(prediction * 100, 2), - "confidence_interval_95": { - "low": round((prediction - 1.96 * pred_std) * 100, 2), - "high": round((prediction + 1.96 * pred_std) * 100, 2), - }, - "model_info": { - "features": feature_cols, - "train_samples": len(train), - "val_samples": len(val), - "test_samples": len(test), - "test_mse": round(test_metrics["mse"], 6), - "test_r2": round(test_metrics["r2"], 3), - }, - "caveats": [ - "Prediction based on historical patterns only", - "Confidence interval estimated from test set errors", - "Past performance does not guarantee future results", - "This is not financial advice", - ], - "generated_at": datetime.now().isoformat(), - } - - # Write to output file - OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(OUTPUT_PATH, "w") as f: - json.dump(result, f, indent=2) - print(f"\nPrediction saved to: {OUTPUT_PATH}") - - # Print human-readable summary - print_prediction(result) - - -if __name__ == "__main__": - main() From 6d48ede66a9bb748d9b48f2c510bb382e7cc4c8a Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:23:25 +0200 Subject: [PATCH 11/19] refactor: remove ticker references from CLI and related scripts - Eliminate DEFAULT_TICKER constant and related parsing - Update run_experiment function to remove ticker parameter --- src/cli/etf-backtest/constants.ts | 1 - src/cli/etf-backtest/main.ts | 8 ++++---- src/cli/etf-backtest/schemas.ts | 2 -- src/cli/etf-backtest/scripts/run_experiment.py | 6 ++---- 4 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/cli/etf-backtest/constants.ts b/src/cli/etf-backtest/constants.ts index 7e3d0ee..482c4c3 100644 --- a/src/cli/etf-backtest/constants.ts +++ b/src/cli/etf-backtest/constants.ts @@ -1,7 +1,6 @@ import path from "node:path"; export const DEFAULT_VERBOSE = false; -export const DEFAULT_TICKER = "SPY"; export const DEFAULT_MAX_ITERATIONS = 5; export const DEFAULT_SEED = 42; export const DEFAULT_REFRESH = false; diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 758dd20..3bf9921 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -40,7 +40,7 @@ import { computeScore } from "./utils/scoring"; const logger = new Logger(); // --- Parse CLI arguments --- -const { verbose, ticker, isin, refresh, maxIterations, seed } = parseArgs({ +const { verbose, isin, refresh, maxIterations, seed } = parseArgs({ logger, schema: CliArgsSchema, }); @@ -101,7 +101,7 @@ IMPORTANT: Run exactly ONE experiment per turn. Do not run multiple experiments. Call runPython with: - scriptName: "run_experiment.py" -- input: { "ticker": "", "featureIds": [...], "seed": , "dataPath": "" } +- input: { "featureIds": [...], "seed": , "dataPath": "" } After you receive results, respond with your analysis. Do not call runPython again in the same turn. @@ -143,14 +143,14 @@ const runAgentOptimization = async (dataPath: string) => { // Initial prompt let currentPrompt = ` -Start feature selection optimization for ${ticker} (ISIN: ${isin}). +Start feature selection optimization for ISIN ${isin}. Begin by selecting ${MIN_FEATURES}-${MAX_FEATURES} features that you think will best predict ${PREDICTION_HORIZON_MONTHS}-month returns. Consider using a mix from each category (momentum, trend, risk). Use runPython with: - scriptName: "run_experiment.py" -- input: { "ticker": "${ticker}", "featureIds": [...your features...], "seed": ${seed}, "dataPath": "${dataPath}" } +- input: { "featureIds": [...your features...], "seed": ${seed}, "dataPath": "${dataPath}" } After running the experiment, analyze the results and decide whether to continue or stop. `; diff --git a/src/cli/etf-backtest/schemas.ts b/src/cli/etf-backtest/schemas.ts index 129dbb9..7e74ce0 100644 --- a/src/cli/etf-backtest/schemas.ts +++ b/src/cli/etf-backtest/schemas.ts @@ -5,7 +5,6 @@ import { DEFAULT_MAX_ITERATIONS, DEFAULT_REFRESH, DEFAULT_SEED, - DEFAULT_TICKER, DEFAULT_VERBOSE, } from "./constants"; @@ -16,7 +15,6 @@ const IsinSchema = z export const CliArgsSchema = z.object({ verbose: z.coerce.boolean().default(DEFAULT_VERBOSE), - ticker: z.string().default(DEFAULT_TICKER), isin: IsinSchema.default(DEFAULT_ISIN), refresh: z.coerce.boolean().default(DEFAULT_REFRESH), maxIterations: z.coerce.number().default(DEFAULT_MAX_ITERATIONS), diff --git a/src/cli/etf-backtest/scripts/run_experiment.py b/src/cli/etf-backtest/scripts/run_experiment.py index ff251aa..5ae53c3 100644 --- a/src/cli/etf-backtest/scripts/run_experiment.py +++ b/src/cli/etf-backtest/scripts/run_experiment.py @@ -7,7 +7,6 @@ Input format: { - "ticker": "SPY", "featureIds": ["mom_1m", "mom_3m", "vol_1m", "px_sma50"], "seed": 42 } @@ -250,7 +249,7 @@ def compute_uncertainty_adjusted( } -def run_experiment(ticker: str, feature_ids: list[str], seed: int, data_path: Path) -> dict: +def run_experiment(feature_ids: list[str], seed: int, data_path: Path) -> dict: """Run a single experiment with given features.""" set_seed(seed) @@ -348,7 +347,6 @@ def main(): print(json.dumps({"error": f"Invalid JSON input: {e}"}), file=sys.stdout) sys.exit(1) - ticker = input_data.get("ticker", "SPY") feature_ids = input_data.get("featureIds") if feature_ids is None: feature_ids = input_data.get("feature_ids", []) @@ -370,7 +368,7 @@ def main(): sys.exit(1) try: - result = run_experiment(ticker, feature_ids, seed, data_path) + result = run_experiment(feature_ids, seed, data_path) print(json.dumps(result, indent=2), file=sys.stdout) except Exception as e: print(json.dumps({"error": str(e)}), file=sys.stdout) From d19ff509170dd38872f781c1da5218e20958ce60 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:49:04 +0200 Subject: [PATCH 12/19] docs: update README and agent documentation for ETF backtest usage - Clarify CLI arguments and usage for ETF backtest - Update data fetching and caching details in documentation - Modify logging method in final report utility --- AGENTS.md | 2 +- README.md | 30 +++++++++++++++++++--- src/cli/etf-backtest/README.md | 23 ++++++++++------- src/cli/etf-backtest/main.ts | 10 +++++--- src/cli/etf-backtest/utils/final-report.ts | 2 +- 5 files changed, 50 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f38013..791d0f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ All file tools are sandboxed to `tmp/` using path validation (`src/tools/utils/f - Params: `{ path?: string }` (defaults to `tmp/` root) - **`runPython`** (`src/tools/run-python/run-python-tool.ts`) - Runs a Python script from a configured scripts directory. - - Params: `{ scriptName: string }` + - Params: `{ scriptName: string, input: string }` (input is JSON string; pass `""` for no input) ### Safe web fetch tool diff --git a/README.md b/README.md index 2aaca8b..6daaec8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A minimal TypeScript CLI sandbox for testing agent workflows and safe web scrapi 5. Run the demo: `pnpm run:guestbook` 6. (Optional) Explore Finnish name stats: `pnpm run:name-explorer -- --mode ai|stats` 7. (Optional) Run publication scraping: `pnpm run:scrape-publications -- --url="https://example.com"` -8. (Optional) Run ETF backtest: `pnpm run:etf-backtest` (requires Python setup below) +8. (Optional) Run ETF backtest: `pnpm run:etf-backtest -- --isin=IE00B5BMR087` (requires Python setup below) ### Python Setup (for ETF backtest) @@ -31,7 +31,7 @@ pip install numpy pandas torch | `pnpm run:guestbook` | Run the interactive guestbook CLI demo | | `pnpm run:name-explorer` | Explore Finnish name statistics (AI Q&A or stats) | | `pnpm run:scrape-publications` | Scrape publication links and build a review page | -| `pnpm run:etf-backtest` | Run neural network ETF backtest (requires Python) | +| `pnpm run:etf-backtest` | Run ETF backtest + feature optimizer (requires Python) | | `pnpm typecheck` | Run TypeScript type checking | | `pnpm lint` | Run ESLint for code quality | | `pnpm lint:fix` | Run ESLint and auto-fix issues | @@ -69,6 +69,21 @@ pnpm run:name-explorer -- [--mode ai|stats] [--refetch] Outputs are written under `tmp/name-explorer/`, including `statistics.html` in stats mode. +## ETF backtest + +The `run:etf-backtest` CLI fetches ETF history from justetf.com (via Playwright), caches it under +`tmp/etf-backtest//data.json`, and runs the Python experiment loop via the `runPython` tool. + +Usage: + +``` +pnpm run:etf-backtest -- --isin=IE00B5BMR087 [--maxIterations=5] [--seed=42] [--refresh] [--verbose] +``` + +Notes: +- `--refresh` forces a refetch; otherwise cached data is reused. +- Python scripts live in `src/cli/etf-backtest/scripts/`. + ## Tools File tools are sandboxed to the `tmp/` directory with path validation to prevent traversal and symlink attacks. The `fetchUrl` tool adds SSRF protections and HTML sanitization, and `runPython` executes whitelisted Python scripts from a configured directory. @@ -79,7 +94,11 @@ File tools are sandboxed to the `tmp/` directory with path validation to prevent | `readFile` | `src/tools/read-file/read-file-tool.ts` | Reads file content from `tmp` directory | | `writeFile` | `src/tools/write-file/write-file-tool.ts` | Writes content to files in `tmp` directory | | `listFiles` | `src/tools/list-files/list-files-tool.ts` | Lists files and directories under `tmp` | -| `runPython` | `src/tools/run-python/run-python-tool.ts` | Runs Python scripts from a configured scripts directory | +| `runPython` | `src/tools/run-python/run-python-tool.ts` | Runs Python scripts from a configured scripts directory (JSON stdin supported) | + +`runPython` details: +- `scriptName` must be a `.py` file name in the configured scripts directory (no subpaths). +- `input` is a JSON string passed to stdin (use `""` for no input). ## Project Structure @@ -89,6 +108,11 @@ src/ │ ├── etf-backtest/ │ │ ├── main.ts # ETF backtest CLI entry point │ │ ├── README.md # ETF backtest docs +│ │ ├── constants.ts # CLI constants +│ │ ├── schemas.ts # CLI args + agent output schemas +│ │ ├── clients/ # Data fetcher + Playwright capture +│ │ ├── utils/ # Scoring + formatting helpers +│ │ ├── types/ # ETF data types │ │ └── scripts/ # Python backtest + prediction scripts │ ├── guestbook/ │ │ ├── main.ts # Guestbook CLI entry point diff --git a/src/cli/etf-backtest/README.md b/src/cli/etf-backtest/README.md index c797a7d..2df0cff 100644 --- a/src/cli/etf-backtest/README.md +++ b/src/cli/etf-backtest/README.md @@ -7,7 +7,8 @@ The agent selects price-only features, runs experiments, and optimizes for **pre ## Requirements - Python 3 with `numpy`, `pandas`, and `torch` installed (see repo README for setup) -- ETF data at `tmp/etf-backtest/data.json` +- Playwright system deps (Chromium) for data fetch (see repo README) +- ETF data cached under `tmp/etf-backtest//data.json` (auto-fetched; use `--refresh`) ## Run @@ -16,17 +17,18 @@ The agent selects price-only features, runs experiments, and optimizes for **pre pnpm run:etf-backtest # With options -pnpm run:etf-backtest --ticker=SPY --maxIterations=5 --seed=42 --verbose +pnpm run:etf-backtest --isin=IE00B5BMR087 --maxIterations=5 --seed=42 --verbose --refresh ``` ## Arguments -| Argument | Default | Description | -| ----------------- | ------- | ------------------------------- | -| `--ticker` | `SPY` | ETF ticker symbol | -| `--maxIterations` | `5` | Maximum optimization iterations | -| `--seed` | `42` | Random seed for reproducibility | -| `--verbose` | `false` | Enable verbose logging | +| Argument | Default | Description | +| ----------------- | -------------- | ----------------------------------------- | +| `--isin` | `IE00B5BMR087` | ETF ISIN (used to fetch/cached data) | +| `--maxIterations` | `5` | Maximum optimization iterations | +| `--seed` | `42` | Random seed for reproducibility | +| `--refresh` | `false` | Force refetch even if cache exists | +| `--verbose` | `false` | Enable verbose logging | ## Feature Menu @@ -143,7 +145,7 @@ The 95% confidence interval uses adjusted uncertainty that accounts for: ## Data Format -Expects `tmp/etf-backtest/data.json`: +Expects `tmp/etf-backtest//data.json` when run via the CLI: ```json { @@ -152,3 +154,6 @@ Expects `tmp/etf-backtest/data.json`: ] } ``` + +If you run `run_experiment.py` directly, it defaults to `tmp/etf-backtest/data.json` +unless you pass `"dataPath"` in the JSON stdin payload. diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 3bf9921..67eb048 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -268,15 +268,19 @@ After running the experiment, analyze the results and decide whether to continue break; } - // Build next prompt + // Build next prompt with dataPath (required since stateless mode loses context) currentPrompt = ` -Your previous experiment is complete. Results are in your conversation history. +Continue feature selection optimization for ISIN ${isin}. You have ${maxIterations - iteration} iterations remaining. -Based on the metrics, decide: +Based on your previous experiment, decide: - If you want to try different features, select them and run another experiment - If you think you've found a good set, respond with status "final" +Use runPython with: +- scriptName: "run_experiment.py" +- input: { "featureIds": [...your features...], "seed": ${seed}, "dataPath": "${dataPath}" } + Focus on: Higher r2NonOverlapping, higher directionAccuracyNonOverlapping, lower MAE. Backtest metrics (Sharpe, drawdown) are informational only. `; diff --git a/src/cli/etf-backtest/utils/final-report.ts b/src/cli/etf-backtest/utils/final-report.ts index 82b4fd2..9cc76f5 100644 --- a/src/cli/etf-backtest/utils/final-report.ts +++ b/src/cli/etf-backtest/utils/final-report.ts @@ -111,5 +111,5 @@ export const printFinalResults = ( LINE_SEPARATOR, ]; - logger.info(lines.join("\n")); + logger.answer(lines.join("\n")); }; From 9eb364ed3e0cb4fc5e5d140021718b7366137adb Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:07:47 +0200 Subject: [PATCH 13/19] feat: implement learnings manager for ETF backtest optimization - Add LearningsManager class for managing learnings persistence - Introduce learnings schema and formatter for prompt-friendly summaries - Update main logic to utilize learnings during optimization runs --- README.md | 36 ++-- src/cli/etf-backtest/README.md | 14 +- .../etf-backtest/clients/etf-data-fetcher.ts | 26 ++- .../etf-backtest/clients/learnings-manager.ts | 154 ++++++++++++++++++ src/cli/etf-backtest/constants.ts | 5 + src/cli/etf-backtest/main.ts | 46 +++++- src/cli/etf-backtest/schemas.ts | 34 ++++ .../etf-backtest/utils/learnings-formatter.ts | 115 +++++++++++++ src/cli/name-explorer/clients/pipeline.ts | 13 +- src/clients/playwright-scraper.ts | 9 +- 10 files changed, 397 insertions(+), 55 deletions(-) create mode 100644 src/cli/etf-backtest/clients/learnings-manager.ts create mode 100644 src/cli/etf-backtest/utils/learnings-formatter.ts diff --git a/README.md b/README.md index 6daaec8..43dc665 100644 --- a/README.md +++ b/README.md @@ -26,18 +26,18 @@ pip install numpy pandas torch ## Commands -| Command | Description | -| ------------------------------ | ------------------------------------------------- | -| `pnpm run:guestbook` | Run the interactive guestbook CLI demo | -| `pnpm run:name-explorer` | Explore Finnish name statistics (AI Q&A or stats) | -| `pnpm run:scrape-publications` | Scrape publication links and build a review page | +| Command | Description | +| ------------------------------ | ------------------------------------------------------ | +| `pnpm run:guestbook` | Run the interactive guestbook CLI demo | +| `pnpm run:name-explorer` | Explore Finnish name statistics (AI Q&A or stats) | +| `pnpm run:scrape-publications` | Scrape publication links and build a review page | | `pnpm run:etf-backtest` | Run ETF backtest + feature optimizer (requires Python) | -| `pnpm typecheck` | Run TypeScript type checking | -| `pnpm lint` | Run ESLint for code quality | -| `pnpm lint:fix` | Run ESLint and auto-fix issues | -| `pnpm format` | Format code with Prettier | -| `pnpm format:check` | Check code formatting | -| `pnpm test` | Run Vitest test suite | +| `pnpm typecheck` | Run TypeScript type checking | +| `pnpm lint` | Run ESLint for code quality | +| `pnpm lint:fix` | Run ESLint and auto-fix issues | +| `pnpm format` | Format code with Prettier | +| `pnpm format:check` | Check code formatting | +| `pnpm test` | Run Vitest test suite | ## Publication scraping @@ -81,6 +81,7 @@ pnpm run:etf-backtest -- --isin=IE00B5BMR087 [--maxIterations=5] [--seed=42] [-- ``` Notes: + - `--refresh` forces a refetch; otherwise cached data is reused. - Python scripts live in `src/cli/etf-backtest/scripts/`. @@ -88,15 +89,16 @@ Notes: File tools are sandboxed to the `tmp/` directory with path validation to prevent traversal and symlink attacks. The `fetchUrl` tool adds SSRF protections and HTML sanitization, and `runPython` executes whitelisted Python scripts from a configured directory. -| Tool | Location | Description | -| ----------- | ----------------------------------------- | ------------------------------------------------------- | -| `fetchUrl` | `src/tools/fetch-url/fetch-url-tool.ts` | Fetches URLs safely and returns sanitized Markdown/text | -| `readFile` | `src/tools/read-file/read-file-tool.ts` | Reads file content from `tmp` directory | -| `writeFile` | `src/tools/write-file/write-file-tool.ts` | Writes content to files in `tmp` directory | -| `listFiles` | `src/tools/list-files/list-files-tool.ts` | Lists files and directories under `tmp` | +| Tool | Location | Description | +| ----------- | ----------------------------------------- | ------------------------------------------------------------------------------ | +| `fetchUrl` | `src/tools/fetch-url/fetch-url-tool.ts` | Fetches URLs safely and returns sanitized Markdown/text | +| `readFile` | `src/tools/read-file/read-file-tool.ts` | Reads file content from `tmp` directory | +| `writeFile` | `src/tools/write-file/write-file-tool.ts` | Writes content to files in `tmp` directory | +| `listFiles` | `src/tools/list-files/list-files-tool.ts` | Lists files and directories under `tmp` | | `runPython` | `src/tools/run-python/run-python-tool.ts` | Runs Python scripts from a configured scripts directory (JSON stdin supported) | `runPython` details: + - `scriptName` must be a `.py` file name in the configured scripts directory (no subpaths). - `input` is a JSON string passed to stdin (use `""` for no input). diff --git a/src/cli/etf-backtest/README.md b/src/cli/etf-backtest/README.md index 2df0cff..cd2faca 100644 --- a/src/cli/etf-backtest/README.md +++ b/src/cli/etf-backtest/README.md @@ -22,13 +22,13 @@ pnpm run:etf-backtest --isin=IE00B5BMR087 --maxIterations=5 --seed=42 --verbose ## Arguments -| Argument | Default | Description | -| ----------------- | -------------- | ----------------------------------------- | -| `--isin` | `IE00B5BMR087` | ETF ISIN (used to fetch/cached data) | -| `--maxIterations` | `5` | Maximum optimization iterations | -| `--seed` | `42` | Random seed for reproducibility | -| `--refresh` | `false` | Force refetch even if cache exists | -| `--verbose` | `false` | Enable verbose logging | +| Argument | Default | Description | +| ----------------- | -------------- | ------------------------------------ | +| `--isin` | `IE00B5BMR087` | ETF ISIN (used to fetch/cached data) | +| `--maxIterations` | `5` | Maximum optimization iterations | +| `--seed` | `42` | Random seed for reproducibility | +| `--refresh` | `false` | Force refetch even if cache exists | +| `--verbose` | `false` | Enable verbose logging | ## Feature Menu diff --git a/src/cli/etf-backtest/clients/etf-data-fetcher.ts b/src/cli/etf-backtest/clients/etf-data-fetcher.ts index 8d0180f..993b13c 100644 --- a/src/cli/etf-backtest/clients/etf-data-fetcher.ts +++ b/src/cli/etf-backtest/clients/etf-data-fetcher.ts @@ -1,12 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; - import type { Logger } from "~clients/logger"; import { PlaywrightScraper } from "~clients/playwright-scraper"; -import { - resolveTmpPathForRead, - resolveTmpPathForWrite, -} from "~tools/utils/fs"; +import { resolveTmpPathForRead, resolveTmpPathForWrite } from "~tools/utils/fs"; import { API_CAPTURE_TIMEOUT_MS, @@ -105,15 +101,17 @@ export class EtfDataFetcher { url: profileUrl, }); - const result = await this.scraper.scrapeWithNetworkCapture({ - targetUrl: profileUrl, - captureUrlPattern: getEtfApiPattern(isin), - captureTimeoutMs: API_CAPTURE_TIMEOUT_MS, - validateResponse: isEtfDataResponse, - localStorage: { - [ETF_CHART_PERIOD_KEY]: ETF_CHART_PERIOD_VALUE, - }, - }); + const result = await this.scraper.scrapeWithNetworkCapture( + { + targetUrl: profileUrl, + captureUrlPattern: getEtfApiPattern(isin), + captureTimeoutMs: API_CAPTURE_TIMEOUT_MS, + validateResponse: isEtfDataResponse, + localStorage: { + [ETF_CHART_PERIOD_KEY]: ETF_CHART_PERIOD_VALUE, + }, + } + ); const validated = EtfDataResponseSchema.parse(result.data); diff --git a/src/cli/etf-backtest/clients/learnings-manager.ts b/src/cli/etf-backtest/clients/learnings-manager.ts new file mode 100644 index 0000000..cee91cd --- /dev/null +++ b/src/cli/etf-backtest/clients/learnings-manager.ts @@ -0,0 +1,154 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { Logger } from "~clients/logger"; +import { resolveTmpPathForRead, resolveTmpPathForWrite } from "~tools/utils/fs"; + +import { + ETF_DATA_DIR, + LEARNINGS_FILENAME, + MAX_HISTORY_ITEMS, +} from "../constants"; +import type { ExperimentResult, IterationRecord, Learnings } from "../schemas"; +import { LearningsSchema } from "../schemas"; +import { computeScore } from "../utils/scoring"; + +export type LearningsManagerConfig = { + logger: Logger; +}; + +/** + * Manages learnings persistence for ETF backtest optimization. + * Stores iteration history and best results per ISIN. + */ +export class LearningsManager { + private logger: Logger; + + constructor(config: LearningsManagerConfig) { + this.logger = config.logger; + } + + /** + * Build the relative path for learnings file (relative to tmp/). + */ + private getLearningsPath(isin: string): string { + return path.join(ETF_DATA_DIR, isin, LEARNINGS_FILENAME); + } + + /** + * Load existing learnings from disk. + * Returns null if no learnings exist. + */ + async load(isin: string): Promise { + try { + const learningsPath = await resolveTmpPathForRead( + this.getLearningsPath(isin) + ); + const content = await fs.readFile(learningsPath, "utf8"); + const json = JSON.parse(content) as unknown; + const validated = LearningsSchema.parse(json); + this.logger.info("Loaded existing learnings", { + isin, + totalIterations: validated.totalIterations, + historyCount: validated.history.length, + }); + return validated; + } catch { + this.logger.info("No existing learnings found", { isin }); + return null; + } + } + + /** + * Create initial learnings structure for a new ISIN. + */ + createInitial(isin: string): Learnings { + const now = new Date().toISOString(); + return { + isin, + createdAt: now, + updatedAt: now, + totalIterations: 0, + bestResult: null, + history: [], + }; + } + + /** + * Add an iteration result to learnings. + * Updates bestResult if this iteration is better. + */ + addIteration( + learnings: Learnings, + iteration: number, + result: ExperimentResult + ): Learnings { + const score = computeScore(result.metrics); + const isBest = + learnings.bestResult === null || score > learnings.bestResult.score; + + const record: IterationRecord = { + iteration: learnings.totalIterations + iteration, + timestamp: new Date().toISOString(), + featureIds: result.featureIds, + score, + metrics: { + r2NonOverlapping: result.metrics.r2NonOverlapping, + directionAccuracyNonOverlapping: + result.metrics.directionAccuracyNonOverlapping, + mae: result.metrics.mae, + sharpe: result.metrics.sharpe, + }, + wasBest: isBest, + }; + + // Update history, trimming to max size (keep most recent) + const newHistory = [...learnings.history, record]; + if (newHistory.length > MAX_HISTORY_ITEMS) { + newHistory.shift(); + } + + const updatedLearnings: Learnings = { + ...learnings, + updatedAt: new Date().toISOString(), + history: newHistory, + }; + + // Update best result if this is better + if (isBest) { + updatedLearnings.bestResult = { + iteration: record.iteration, + featureIds: result.featureIds, + score, + metrics: record.metrics, + }; + } + + return updatedLearnings; + } + + /** + * Increment total iterations counter (called at end of run). + */ + finishRun(learnings: Learnings, iterationsCompleted: number): Learnings { + return { + ...learnings, + totalIterations: learnings.totalIterations + iterationsCompleted, + updatedAt: new Date().toISOString(), + }; + } + + /** + * Save learnings to disk. + */ + async save(isin: string, learnings: Learnings): Promise { + const learningsPath = await resolveTmpPathForWrite( + this.getLearningsPath(isin) + ); + await fs.writeFile( + learningsPath, + JSON.stringify(learnings, null, 2), + "utf8" + ); + this.logger.debug("Saved learnings", { isin, path: learningsPath }); + } +} diff --git a/src/cli/etf-backtest/constants.ts b/src/cli/etf-backtest/constants.ts index 482c4c3..d9fa6b2 100644 --- a/src/cli/etf-backtest/constants.ts +++ b/src/cli/etf-backtest/constants.ts @@ -96,3 +96,8 @@ export const FEATURE_MENU = { risk: ["vol_1m", "vol_3m", "vol_6m", "dd_current", "mdd_12m"], oscillators: ["rsi_14", "bb_width"], } as const; + +// Learnings configuration +export const LEARNINGS_FILENAME = "learnings.json"; +export const MAX_HISTORY_ITEMS = 20; +export const LEARNINGS_SUMMARY_TOP_N = 5; diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 67eb048..2f26170 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -11,6 +11,7 @@ import { createRunPythonTool } from "~tools/run-python/run-python-tool"; import { parseArgs } from "~utils/parse-args"; import { EtfDataFetcher } from "./clients/etf-data-fetcher"; +import { LearningsManager } from "./clients/learnings-manager"; import { DECIMAL_PLACES, FEATURE_MENU, @@ -31,10 +32,11 @@ import { ZERO, } from "./constants"; import { AgentOutputSchema, CliArgsSchema } from "./schemas"; -import type { ExperimentResult } from "./schemas"; +import type { ExperimentResult, Learnings } from "./schemas"; import { extractLastExperimentResult } from "./utils/experiment-extract"; import { printFinalResults } from "./utils/final-report"; import { formatFixed, formatPercent } from "./utils/formatters"; +import { formatLearningsForPrompt } from "./utils/learnings-formatter"; import { computeScore } from "./utils/scoring"; const logger = new Logger(); @@ -116,7 +118,11 @@ After each experiment, respond with JSON (do not call any more tools): `; // --- Run iterative optimization --- -const runAgentOptimization = async (dataPath: string) => { +const runAgentOptimization = async ( + dataPath: string, + initialLearnings: Learnings, + learningsManager: LearningsManager +) => { const runPythonTool = createRunPythonTool({ scriptsDir: SCRIPTS_DIR, logger, @@ -140,11 +146,13 @@ const runAgentOptimization = async (dataPath: string) => { let noImprovementCount = ZERO; let iteration = ZERO; let stopReason = "Max iterations reached"; + let learnings = initialLearnings; - // Initial prompt + // Initial prompt with learnings context + const learningsSummary = formatLearningsForPrompt(learnings); let currentPrompt = ` Start feature selection optimization for ISIN ${isin}. - +${learningsSummary} Begin by selecting ${MIN_FEATURES}-${MAX_FEATURES} features that you think will best predict ${PREDICTION_HORIZON_MONTHS}-month returns. Consider using a mix from each category (momentum, trend, risk). @@ -253,6 +261,14 @@ After running the experiment, analyze the results and decide whether to continue maxNoImprovement: MAX_NO_IMPROVEMENT, }); } + + // Record iteration in learnings and save progress + learnings = learningsManager.addIteration( + learnings, + iteration, + lastToolResult + ); + await learningsManager.save(isin, learnings); } // Check stop conditions @@ -268,11 +284,12 @@ After running the experiment, analyze the results and decide whether to continue break; } - // Build next prompt with dataPath (required since stateless mode loses context) + // Build next prompt with learnings context (required since stateless mode loses context) + const updatedLearningsSummary = formatLearningsForPrompt(learnings); currentPrompt = ` Continue feature selection optimization for ISIN ${isin}. You have ${maxIterations - iteration} iterations remaining. - +${updatedLearningsSummary} Based on your previous experiment, decide: - If you want to try different features, select them and run another experiment - If you think you've found a good set, respond with status "final" @@ -286,6 +303,14 @@ Backtest metrics (Sharpe, drawdown) are informational only. `; } + // Finalize learnings and save + learnings = learningsManager.finishRun(learnings, iteration); + await learningsManager.save(isin, learnings); + logger.info("Learnings saved", { + totalIterations: learnings.totalIterations, + historyCount: learnings.history.length, + }); + // Output final results if (bestResult) { printFinalResults(logger, bestResult, bestIteration, iteration, stopReason); @@ -301,9 +326,16 @@ if (verbose) { } const fetcher = new EtfDataFetcher({ logger }); +const learningsManager = new LearningsManager({ logger }); + try { const { dataPath } = await fetcher.fetch(isin, refresh); - await runAgentOptimization(dataPath); + + // Load or create learnings + let learnings = await learningsManager.load(isin); + learnings ??= learningsManager.createInitial(isin); + + await runAgentOptimization(dataPath, learnings, learningsManager); } finally { await fetcher.close(); } diff --git a/src/cli/etf-backtest/schemas.ts b/src/cli/etf-backtest/schemas.ts index 7e74ce0..4fac532 100644 --- a/src/cli/etf-backtest/schemas.ts +++ b/src/cli/etf-backtest/schemas.ts @@ -73,3 +73,37 @@ export const ExperimentResultSchema = z.object({ }); export type ExperimentResult = z.infer; + +// Single iteration record - captures what was tried and what happened +export const IterationRecordSchema = z.object({ + iteration: z.number(), + timestamp: z.string(), + featureIds: z.array(z.string()), + score: z.number(), + metrics: z.object({ + r2NonOverlapping: z.number(), + directionAccuracyNonOverlapping: z.number(), + mae: z.number(), + sharpe: z.number(), + }), + wasBest: z.boolean(), +}); + +export type IterationRecord = z.infer; + +const BestResultSchema = IterationRecordSchema.omit({ + timestamp: true, + wasBest: true, +}); + +// Complete learnings file structure +export const LearningsSchema = z.object({ + isin: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + totalIterations: z.number(), + bestResult: BestResultSchema.nullable(), + history: z.array(IterationRecordSchema), +}); + +export type Learnings = z.infer; diff --git a/src/cli/etf-backtest/utils/learnings-formatter.ts b/src/cli/etf-backtest/utils/learnings-formatter.ts new file mode 100644 index 0000000..a800fd1 --- /dev/null +++ b/src/cli/etf-backtest/utils/learnings-formatter.ts @@ -0,0 +1,115 @@ +import { DECIMAL_PLACES, LEARNINGS_SUMMARY_TOP_N } from "../constants"; +import type { Learnings } from "../schemas"; +import { formatFixed, formatPercent } from "./formatters"; + +const FEATURE_PREVIEW_COUNT = 4; +const TOP_HALF_DIVISOR = 2; +const TOP_FEATURES_COUNT = 5; +const FEATURES_TO_AVOID_COUNT = 3; + +/** + * Format learnings into a prompt-friendly text summary. + * Returns empty string if no useful learnings exist. + */ +export const formatLearningsForPrompt = ( + learnings: Learnings | null +): string => { + if (!learnings || learnings.history.length === 0) { + return ""; + } + + const lines: string[] = [ + "", + "## Previous Learnings", + `Total iterations run: ${learnings.totalIterations}`, + ]; + + // Best result summary + if (learnings.bestResult) { + lines.push(""); + lines.push("**Best result so far:**"); + lines.push(`- Features: ${learnings.bestResult.featureIds.join(", ")}`); + lines.push( + `- Score: ${formatFixed(learnings.bestResult.score, DECIMAL_PLACES.score)}` + ); + lines.push( + `- R² (non-overlapping): ${formatFixed(learnings.bestResult.metrics.r2NonOverlapping, DECIMAL_PLACES.r2)}` + ); + lines.push( + `- Direction accuracy: ${formatPercent(learnings.bestResult.metrics.directionAccuracyNonOverlapping)}` + ); + lines.push(`- MAE: ${formatPercent(learnings.bestResult.metrics.mae)}`); + } + + // Top N best attempts (sorted by score) + const sortedHistory = [...learnings.history] + .sort((a, b) => b.score - a.score) + .slice(0, LEARNINGS_SUMMARY_TOP_N); + + if (sortedHistory.length > 1) { + lines.push(""); + lines.push(`**Top ${sortedHistory.length} attempts:**`); + for (const record of sortedHistory) { + const featurePreview = record.featureIds.slice(0, FEATURE_PREVIEW_COUNT); + const suffix = + record.featureIds.length > FEATURE_PREVIEW_COUNT ? "..." : ""; + lines.push( + `- [Score ${formatFixed(record.score, DECIMAL_PLACES.score)}] ` + + `Features: ${featurePreview.join(", ")}${suffix}` + ); + } + } + + // Feature frequency analysis (which features appear in best results?) + const featureFrequency = new Map(); + const topHalf = sortedHistory.slice( + 0, + Math.ceil(sortedHistory.length / TOP_HALF_DIVISOR) + ); + for (const record of topHalf) { + for (const feature of record.featureIds) { + featureFrequency.set(feature, (featureFrequency.get(feature) ?? 0) + 1); + } + } + + const frequentFeatures = [...featureFrequency.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_FEATURES_COUNT) + .map(([feature]) => feature); + + if (frequentFeatures.length > 0) { + lines.push(""); + lines.push( + `**Features common in top results:** ${frequentFeatures.join(", ")}` + ); + } + + // Identify features that consistently appear in poor results + const bottomHalf = sortedHistory.slice( + Math.ceil(sortedHistory.length / TOP_HALF_DIVISOR) + ); + const poorFeatures = new Map(); + for (const record of bottomHalf) { + for (const feature of record.featureIds) { + poorFeatures.set(feature, (poorFeatures.get(feature) ?? 0) + 1); + } + } + + // Features in bottom half but not in top half + const toAvoid = [...poorFeatures.entries()] + .filter(([feature]) => !featureFrequency.has(feature)) + .slice(0, FEATURES_TO_AVOID_COUNT) + .map(([feature]) => feature); + + if (toAvoid.length > 0) { + lines.push(`**Features to reconsider:** ${toAvoid.join(", ")}`); + } + + lines.push(""); + lines.push( + "Use these learnings to guide your feature selection. Try to beat the best score." + ); + lines.push(""); + + return lines.join("\n"); +}; diff --git a/src/cli/name-explorer/clients/pipeline.ts b/src/cli/name-explorer/clients/pipeline.ts index a270b8b..d299e68 100644 --- a/src/cli/name-explorer/clients/pipeline.ts +++ b/src/cli/name-explorer/clients/pipeline.ts @@ -140,14 +140,11 @@ export class NameSuggesterPipeline { decades?: string[]; pages?: number[]; } = {}): Promise { - this.logger.info( - "Processing plan", - { - decades: decades.length, - pages: pages.length, - combinations: decades.length * pages.length, - } - ); + this.logger.info("Processing plan", { + decades: decades.length, + pages: pages.length, + combinations: decades.length * pages.length, + }); let cachedPages = 0; let fetchedPages = 0; diff --git a/src/clients/playwright-scraper.ts b/src/clients/playwright-scraper.ts index b5d9197..b2e4b0d 100644 --- a/src/clients/playwright-scraper.ts +++ b/src/clients/playwright-scraper.ts @@ -281,7 +281,9 @@ export class PlaywrightScraper { // If validator provided, check if response matches expected shape if (validateResponse && !validateResponse(data)) { - this.logger.debug("Response did not pass validation, skipping", { url }); + this.logger.debug("Response did not pass validation, skipping", { + url, + }); await route.fulfill({ response }); return; } @@ -300,7 +302,10 @@ export class PlaywrightScraper { } catch (err) { // Only continue if not already handled and page is still open if (!page.isClosed()) { - this.logger.warn("Failed to capture response", { url, error: err }); + this.logger.warn("Failed to capture response", { + url, + error: err, + }); try { await route.continue(); } catch { From e3ae01b1888653a0585ecf1c27bb4c3df018311e Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:11:19 +0200 Subject: [PATCH 14/19] refactor: remove REASONING_PREVIEW_LIMIT constant and update usage --- src/cli/etf-backtest/constants.ts | 1 - src/cli/etf-backtest/main.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/cli/etf-backtest/constants.ts b/src/cli/etf-backtest/constants.ts index d9fa6b2..505acf4 100644 --- a/src/cli/etf-backtest/constants.ts +++ b/src/cli/etf-backtest/constants.ts @@ -27,7 +27,6 @@ export const ETF_DATA_FILENAME = "data.json"; export const MAX_NO_IMPROVEMENT = 2; export const ZERO = 0; export const MAX_TURNS_PER_ITERATION = 3; -export const REASONING_PREVIEW_LIMIT = 100; export const MIN_FEATURES = 8; export const MAX_FEATURES = 12; diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 2f26170..9830977 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -22,7 +22,6 @@ import { NO_IMPROVEMENT_REASON, OVERLAP_PERCENT, PREDICTION_HORIZON_MONTHS, - REASONING_PREVIEW_LIMIT, SAMPLES_PER_DECADE, SCRIPTS_DIR, TARGET_CALIBRATION_MAX, @@ -219,7 +218,7 @@ After running the experiment, analyze the results and decide whether to continue const output = parseResult.data; logger.info("Features selected", { features: output.selectedFeatures }); logger.info("Reasoning preview", { - preview: output.reasoning.substring(ZERO, REASONING_PREVIEW_LIMIT), + preview: output.reasoning, }); // Try to extract experiment result from the tool call outputs From 1d9adecccfeb411c4045ffa3b38fa03854a1b4c2 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:36:22 +0200 Subject: [PATCH 15/19] docs: update AGENTS and ETF backtest README for clarity and new features - Add usage of AgentRunner as default wrapper for agents - Include flowchart for ETF backtest process - Add demo image for ETF backtest --- AGENTS.md | 3 ++- README.md | 9 ++++--- src/cli/etf-backtest/README.md | 43 ++++++++++++++++++++++++++++++-- src/cli/etf-backtest/demo-1.png | Bin 0 -> 111420 bytes 4 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 src/cli/etf-backtest/demo-1.png diff --git a/AGENTS.md b/AGENTS.md index 791d0f6..c67c81f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,7 @@ All file tools are sandboxed to `tmp/` using path validation (`src/tools/utils/f - Initialize `Logger` in CLI entry points and pass it into clients/pipelines via constructor options. - Use `Logger` instead of `console.log`/`console.error` for output. +- Use `AgentRunner` (`src/clients/agent-runner.ts`) as the default wrapper when running agents. - Prefer shared helpers in `src/utils` (`parse-args`, `question-handler`) over custom logic. - `main.ts` should stay focused on the **basic agent flow**: argument parsing → agent setup → run loop → final output. Move helper logic into `clients/` or `utils/` - Prefer TypeScript path aliases over deep relative imports: `~tools/*`, `~clients/*`, `~utils/*`. @@ -147,4 +148,4 @@ All file tools are sandboxed to `tmp/` using path validation (`src/tools/utils/f # ExecPlans -When writing complex features or significant refactors, use an ExecPlan (as described in .agent/PLANS.md) from design to implementation. +When writing complex features or significant refactors, use an ExecPlan (as described in `agent/PLANS.md`) from design to implementation. diff --git a/README.md b/README.md index 43dc665..2a5ca6e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # cli-agent-sandbox -A minimal TypeScript CLI sandbox for testing agent workflows and safe web scraping. This is a single-package repo built with [`@openai/agents`](https://github.com/openai/openai-agents-js), and it includes a guestbook demo, a Finnish name explorer CLI, a publication scraping pipeline with a Playwright-based scraper for JS-rendered pages, and agent tools scoped to `tmp` with strong safety checks. +A minimal TypeScript CLI sandbox for testing agent workflows and safe web scraping. This is a single-package repo built with [`@openai/agents`](https://github.com/openai/openai-agents-js), and it includes a guestbook demo, a Finnish name explorer CLI, a publication scraping pipeline with a Playwright-based scraper for JS-rendered pages, an ETF backtest CLI, and agent tools scoped to `tmp` with strong safety checks. ## Quick Start @@ -59,7 +59,7 @@ The publication pipeline uses `PlaywrightScraper` to render JavaScript-heavy pag The `run:name-explorer` script explores Finnish name statistics. It supports an AI Q&A mode (default) backed by SQL tools, plus a `stats` mode that generates an HTML report. -![Name Explorer demo](src/cli/name-explorer/demo-1.png) +Name Explorer demo Usage: @@ -74,6 +74,8 @@ Outputs are written under `tmp/name-explorer/`, including `statistics.html` in s The `run:etf-backtest` CLI fetches ETF history from justetf.com (via Playwright), caches it under `tmp/etf-backtest//data.json`, and runs the Python experiment loop via the `runPython` tool. +ETF Backtest demo + Usage: ``` @@ -134,12 +136,12 @@ src/ ├── clients/ │ ├── fetch.ts # Shared HTTP fetch + sanitization │ ├── logger.ts # Shared console logger +│ ├── agent-runner.ts # Default agent runner wrapper │ └── playwright-scraper.ts # Playwright-based web scraper ├── utils/ │ ├── parse-args.ts # Shared CLI arg parsing helper │ └── question-handler.ts # Shared CLI prompt + validation helper ├── tools/ -│ ├── index.ts # Tool exports │ ├── fetch-url/ # Safe fetch tool │ ├── list-files/ # List files tool │ ├── read-file/ # Read file tool @@ -156,6 +158,7 @@ tmp/ # Runtime scratch space (tool I/O) ## CLI conventions - When using `Logger`, initialize it in the CLI entry point and pass it into clients/pipelines via constructor options. +- Use `AgentRunner` (`src/clients/agent-runner.ts`) as the default wrapper when running agents. - Prefer shared helpers in `src/utils` (`parse-args`, `question-handler`) over custom argument parsing or prompt logic. - Use the TypeScript path aliases for shared modules: `~tools/*`, `~clients/*`, `~utils/*`. Example: `import { readFileTool } from "~tools/read-file/read-file-tool";` diff --git a/src/cli/etf-backtest/README.md b/src/cli/etf-backtest/README.md index cd2faca..d6b804f 100644 --- a/src/cli/etf-backtest/README.md +++ b/src/cli/etf-backtest/README.md @@ -4,6 +4,8 @@ Iterative feature selection optimization agent for realistic 12-month ETF return The agent selects price-only features, runs experiments, and optimizes for **prediction accuracy** (not trading performance). It uses non-overlapping evaluation windows for honest assessment. +![ETF Backtest demo](./demo-1.png) + ## Requirements - Python 3 with `numpy`, `pandas`, and `torch` installed (see repo README for setup) @@ -46,8 +48,40 @@ The agent selects 8-12 features from these categories: 1. **Agent selects features** from the menu (starts with 8-12) 2. **Runs experiment** via `run_experiment.py` (backtest + prediction) 3. **Analyzes results**: R² (non-overlapping), direction accuracy, MAE -4. **Decides**: continue with tweaked features or stop -5. **Stops early** if no improvement for 2 iterations +4. **Persists learnings** after each iteration (per-ISIN history + best score) +5. **Decides**: continue with tweaked features or stop +6. **Stops early** if no improvement for 2 iterations + +## Flowchart + +```mermaid +flowchart TD + A["Start CLI"] --> B["Parse args (zod)"] + B --> C["Init Logger, Fetcher, LearningsManager"] + C --> D["Fetch ETF data (cache or refresh)"] + D --> E["Load or create learnings"] + E --> F["Build agent + runPython tool"] + F --> G{"Iteration < maxIterations?"} + + G -->|yes| H["Run AgentRunner (1 experiment)"] + H --> I["Parse agent JSON output"] + I --> J{"Valid output?"} + J -->|no| K["Prompt agent to fix format"] + K --> H + J -->|yes| L["Extract experiment results"] + L --> M["Compute score & update best"] + M --> N["Save learnings"] + N --> O{"Stop conditions?"} + O -->|agent final| P["Set stop reason"] + O -->|no improvement| P + O -->|continue| G + + G -->|no| P + P --> Q["Finalize learnings"] + Q --> R["Print final report"] + R --> S["Close fetcher"] + S --> T["Done"] +``` ## Metrics @@ -120,6 +154,11 @@ Past performance does not guarantee future results. ============================================================ ``` +Writes under `tmp/etf-backtest//`: + +- `data.json`: cached price series used for experiments +- `learnings.json`: per-iteration history and best-result tracking + ## Scripts | Script | Purpose | diff --git a/src/cli/etf-backtest/demo-1.png b/src/cli/etf-backtest/demo-1.png new file mode 100644 index 0000000000000000000000000000000000000000..38c007090899bd5f9f4d7dd508b20d0a4b37114f GIT binary patch literal 111420 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

Rv(G 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 Date: Tue, 27 Jan 2026 09:45:17 +0200 Subject: [PATCH 16/19] refactor: move ETF data schemas to schemas.ts and remove old types - Update imports in etf-data-fetcher.ts to reflect new schema location - Delete outdated etf-data.ts file --- .../etf-backtest/clients/etf-data-fetcher.ts | 4 +-- src/cli/etf-backtest/schemas.ts | 30 +++++++++++++++++ src/cli/etf-backtest/types/etf-data.ts | 32 ------------------- 3 files changed, 32 insertions(+), 34 deletions(-) delete mode 100644 src/cli/etf-backtest/types/etf-data.ts diff --git a/src/cli/etf-backtest/clients/etf-data-fetcher.ts b/src/cli/etf-backtest/clients/etf-data-fetcher.ts index 993b13c..5bc3297 100644 --- a/src/cli/etf-backtest/clients/etf-data-fetcher.ts +++ b/src/cli/etf-backtest/clients/etf-data-fetcher.ts @@ -14,8 +14,8 @@ import { getEtfApiPattern, JUST_ETF_BASE_URL, } from "../constants"; -import type { EtfDataResponse } from "../types/etf-data"; -import { EtfDataResponseSchema, isEtfDataResponse } from "../types/etf-data"; +import type { EtfDataResponse } from "../schemas"; +import { EtfDataResponseSchema, isEtfDataResponse } from "../schemas"; export type EtfDataFetcherConfig = { logger: Logger; diff --git a/src/cli/etf-backtest/schemas.ts b/src/cli/etf-backtest/schemas.ts index 4fac532..7859d09 100644 --- a/src/cli/etf-backtest/schemas.ts +++ b/src/cli/etf-backtest/schemas.ts @@ -107,3 +107,33 @@ export const LearningsSchema = z.object({ }); export type Learnings = z.infer; + +// Value with raw number and localized string representation +export const LocalizedValueSchema = z.object({ + raw: z.number(), + localized: z.string(), +}); + +// Single data point in the time series +export const SeriesPointSchema = z.object({ + date: z.string(), // ISO format: "YYYY-MM-DD" + value: LocalizedValueSchema, +}); + +// Full API response from justetf.com +export const EtfDataResponseSchema = z.object({ + latestQuote: LocalizedValueSchema, + latestQuoteDate: z.string(), + price: LocalizedValueSchema, + performance: LocalizedValueSchema, + prevDaySeries: z.array(SeriesPointSchema), + series: z.array(SeriesPointSchema), +}); + +export type LocalizedValue = z.infer; +export type SeriesPoint = z.infer; +export type EtfDataResponse = z.infer; + +export const isEtfDataResponse = (data: unknown): data is EtfDataResponse => { + return EtfDataResponseSchema.safeParse(data).success; +}; diff --git a/src/cli/etf-backtest/types/etf-data.ts b/src/cli/etf-backtest/types/etf-data.ts deleted file mode 100644 index a2a7876..0000000 --- a/src/cli/etf-backtest/types/etf-data.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from "zod"; - -// Value with raw number and localized string representation -export const LocalizedValueSchema = z.object({ - raw: z.number(), - localized: z.string(), -}); - -// Single data point in the time series -export const SeriesPointSchema = z.object({ - date: z.string(), // ISO format: "YYYY-MM-DD" - value: LocalizedValueSchema, -}); - -// Full API response from justetf.com -export const EtfDataResponseSchema = z.object({ - latestQuote: LocalizedValueSchema, - latestQuoteDate: z.string(), - price: LocalizedValueSchema, - performance: LocalizedValueSchema, - prevDaySeries: z.array(SeriesPointSchema), - series: z.array(SeriesPointSchema), -}); - -export type LocalizedValue = z.infer; -export type SeriesPoint = z.infer; -export type EtfDataResponse = z.infer; - -/** Type guard to check if data matches the expected ETF response shape */ -export const isEtfDataResponse = (data: unknown): data is EtfDataResponse => { - return EtfDataResponseSchema.safeParse(data).success; -}; From d5b92a4272b9dfa309c138eaff4a3d664fd6b628 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:51:58 +0200 Subject: [PATCH 17/19] test: update tool logging in AgentRunner tests for clarity --- src/clients/agent-runner.test.ts | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/clients/agent-runner.test.ts b/src/clients/agent-runner.test.ts index 50678ea..a0433bc 100644 --- a/src/clients/agent-runner.test.ts +++ b/src/clients/agent-runner.test.ts @@ -201,9 +201,10 @@ describe("AgentRunner", () => { handler(null, null, mockTool, mockDetails); - expect(toolLogSpy).toHaveBeenCalledWith( - 'Calling testTool: {"key":"value"}' - ); + expect(toolLogSpy).toHaveBeenCalledWith("Calling tool", { + name: "testTool", + args: '{"key":"value"}', + }); }); it("does not log tool arguments when logToolArgs is false", () => { @@ -227,7 +228,9 @@ describe("AgentRunner", () => { handler(null, null, mockTool, mockDetails); - expect(toolLogSpy).toHaveBeenCalledWith("Calling testTool"); + expect(toolLogSpy).toHaveBeenCalledWith("Calling tool", { + name: "testTool", + }); }); it("logs result preview when logToolResults is true", () => { @@ -249,7 +252,9 @@ describe("AgentRunner", () => { handler(null, null, mockTool, "short result"); - expect(debugLogSpy).toHaveBeenCalledWith("Result: short result"); + expect(debugLogSpy).toHaveBeenCalledWith("Tool result preview", { + preview: "short result", + }); }); it("truncates long results based on resultPreviewLimit", () => { @@ -272,7 +277,9 @@ describe("AgentRunner", () => { handler(null, null, mockTool, "this is a very long result string"); - expect(debugLogSpy).toHaveBeenCalledWith("Result: this is a ..."); + expect(debugLogSpy).toHaveBeenCalledWith("Tool result preview", { + preview: "this is a ...", + }); }); it("does not log result when logToolResults is false", () => { @@ -335,7 +342,9 @@ describe("AgentRunner", () => { handler(null, null, mockTool, mockDetails); // Should not include arguments - expect(toolLogSpy).toHaveBeenCalledWith("Calling testTool"); + expect(toolLogSpy).toHaveBeenCalledWith("Calling tool", { + name: "testTool", + }); }); it("defaults logToolResults to true", () => { @@ -379,9 +388,9 @@ describe("AgentRunner", () => { handler(null, null, mockTool, longResult); // Should truncate at 200 chars - expect(debugLogSpy).toHaveBeenCalledWith( - "Result: " + "x".repeat(200) + "..." - ); + expect(debugLogSpy).toHaveBeenCalledWith("Tool result preview", { + preview: "x".repeat(200) + "...", + }); }); }); }); From 36165eefbc12ca29679e15105886bb17441ac351 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:57:47 +0200 Subject: [PATCH 18/19] feat: add prompt builders for runPython usage and recovery messages - Implement buildRunPythonUsage and buildRecoveryPrompt functions - Update main agent logic to utilize new prompt builders - Add tests for prompt builder functions --- src/cli/etf-backtest/main.ts | 33 +++++++++++-------- .../utils/prompt-builders.test.ts | 31 +++++++++++++++++ src/cli/etf-backtest/utils/prompt-builders.ts | 19 +++++++++++ 3 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 src/cli/etf-backtest/utils/prompt-builders.test.ts create mode 100644 src/cli/etf-backtest/utils/prompt-builders.ts diff --git a/src/cli/etf-backtest/main.ts b/src/cli/etf-backtest/main.ts index 9830977..e11aca4 100644 --- a/src/cli/etf-backtest/main.ts +++ b/src/cli/etf-backtest/main.ts @@ -36,6 +36,10 @@ import { extractLastExperimentResult } from "./utils/experiment-extract"; import { printFinalResults } from "./utils/final-report"; import { formatFixed, formatPercent } from "./utils/formatters"; import { formatLearningsForPrompt } from "./utils/learnings-formatter"; +import { + buildRecoveryPrompt, + buildRunPythonUsage, +} from "./utils/prompt-builders"; import { computeScore } from "./utils/scoring"; const logger = new Logger(); @@ -149,15 +153,14 @@ const runAgentOptimization = async ( // Initial prompt with learnings context const learningsSummary = formatLearningsForPrompt(learnings); + const runPythonUsage = buildRunPythonUsage({ seed, dataPath }); let currentPrompt = ` Start feature selection optimization for ISIN ${isin}. ${learningsSummary} Begin by selecting ${MIN_FEATURES}-${MAX_FEATURES} features that you think will best predict ${PREDICTION_HORIZON_MONTHS}-month returns. Consider using a mix from each category (momentum, trend, risk). -Use runPython with: -- scriptName: "run_experiment.py" -- input: { "featureIds": [...your features...], "seed": ${seed}, "dataPath": "${dataPath}" } +${runPythonUsage} After running the experiment, analyze the results and decide whether to continue or stop. `; @@ -197,8 +200,10 @@ After running the experiment, analyze the results and decide whether to continue bestIteration = iteration; } } - currentPrompt = - "You ran too many experiments in one turn. Please run exactly ONE experiment, then respond with your JSON analysis."; + currentPrompt = buildRecoveryPrompt( + "You ran too many experiments in one turn. Please run exactly ONE experiment, then respond with your JSON analysis.", + { seed, dataPath } + ); continue; } throw err; @@ -210,8 +215,10 @@ After running the experiment, analyze the results and decide whether to continue if (verbose) { logger.debug("Parse error", { error: parseResult.error }); } - currentPrompt = - "Your response was not valid JSON. Please respond with the correct format."; + currentPrompt = buildRecoveryPrompt( + "Your response was not valid JSON. Please respond with the correct format.", + { seed, dataPath } + ); continue; } @@ -288,14 +295,12 @@ After running the experiment, analyze the results and decide whether to continue currentPrompt = ` Continue feature selection optimization for ISIN ${isin}. You have ${maxIterations - iteration} iterations remaining. -${updatedLearningsSummary} -Based on your previous experiment, decide: -- If you want to try different features, select them and run another experiment -- If you think you've found a good set, respond with status "final" + ${updatedLearningsSummary} + Based on your previous experiment, decide: + - If you want to try different features, select them and run another experiment + - If you think you've found a good set, respond with status "final" -Use runPython with: -- scriptName: "run_experiment.py" -- input: { "featureIds": [...your features...], "seed": ${seed}, "dataPath": "${dataPath}" } +${runPythonUsage} Focus on: Higher r2NonOverlapping, higher directionAccuracyNonOverlapping, lower MAE. Backtest metrics (Sharpe, drawdown) are informational only. diff --git a/src/cli/etf-backtest/utils/prompt-builders.test.ts b/src/cli/etf-backtest/utils/prompt-builders.test.ts new file mode 100644 index 0000000..c9eb975 --- /dev/null +++ b/src/cli/etf-backtest/utils/prompt-builders.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { buildRecoveryPrompt, buildRunPythonUsage } from "./prompt-builders"; + +describe("buildRunPythonUsage", () => { + it("includes seed and dataPath in the tool input", () => { + const result = buildRunPythonUsage({ + seed: 7, + dataPath: "tmp/etf-backtest/data.json", + }); + + expect(result).toContain('"seed": 7'); + expect(result).toContain('"dataPath": "tmp/etf-backtest/data.json"'); + expect(result).toContain('scriptName: "run_experiment.py"'); + }); +}); + +describe("buildRecoveryPrompt", () => { + it("appends runPython usage after the message", () => { + const message = "Recovery message."; + const result = buildRecoveryPrompt(message, { + seed: 1, + dataPath: "data.json", + }); + + expect(result.startsWith(message)).toBe(true); + expect(result).toContain("Use runPython with:"); + expect(result).toContain('"seed": 1'); + expect(result).toContain('"dataPath": "data.json"'); + }); +}); diff --git a/src/cli/etf-backtest/utils/prompt-builders.ts b/src/cli/etf-backtest/utils/prompt-builders.ts new file mode 100644 index 0000000..6822bdb --- /dev/null +++ b/src/cli/etf-backtest/utils/prompt-builders.ts @@ -0,0 +1,19 @@ +type RunPythonUsageOptions = { + seed: number; + dataPath: string; +}; + +export const buildRunPythonUsage = ({ + seed, + dataPath, +}: RunPythonUsageOptions): string => + [ + "Use runPython with:", + '- scriptName: "run_experiment.py"', + `- input: { "featureIds": [...your features...], "seed": ${seed}, "dataPath": "${dataPath}" }`, + ].join("\n"); + +export const buildRecoveryPrompt = ( + message: string, + options: RunPythonUsageOptions +): string => [message, "", buildRunPythonUsage(options)].join("\n"); From 3656fd26b4e1a65f2a8f2e4b101d4d9e5f0f3f42 Mon Sep 17 00:00:00 2001 From: Juha Kangas <42040080+valuecodes@users.noreply.github.com> Date: Tue, 27 Jan 2026 10:00:44 +0200 Subject: [PATCH 19/19] refactor: remove redundant Python script tests from run-python-tool --- src/tools/run-python/run-python-tool.test.ts | 125 ------------------- 1 file changed, 125 deletions(-) diff --git a/src/tools/run-python/run-python-tool.test.ts b/src/tools/run-python/run-python-tool.test.ts index 961d867..357a4b8 100644 --- a/src/tools/run-python/run-python-tool.test.ts +++ b/src/tools/run-python/run-python-tool.test.ts @@ -61,51 +61,6 @@ describe("createRunPythonTool", () => { scriptsDir = ""; }); - it("executes a valid Python script", async () => { - const scriptContent = 'print("Hello from Python")'; - await fs.writeFile( - path.join(scriptsDir, "hello.py"), - scriptContent, - "utf8" - ); - - const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); - const resultJson = await invokeTool(tool, { - scriptName: "hello.py", - input: "", - }); - const result = JSON.parse(resultJson) as PythonResult; - - expect(result.success).toBe(true); - expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("Hello from Python"); - expect(result.stderr).toBe(""); - }); - - it("captures stderr from Python script", async () => { - const scriptContent = ` -import sys -sys.stderr.write("Error message") -sys.exit(1) -`; - await fs.writeFile( - path.join(scriptsDir, "error.py"), - scriptContent, - "utf8" - ); - - const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); - const resultJson = await invokeTool(tool, { - scriptName: "error.py", - input: "", - }); - const result = JSON.parse(resultJson) as PythonResult; - - expect(result.success).toBe(false); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain("Error message"); - }); - it("rejects invalid script names", async () => { const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); const resultJson = await invokeTool(tool, { @@ -151,55 +106,6 @@ sys.exit(1) expect(loggedMessages[1]).toContain("Python result"); }); - it("passes JSON input via stdin", async () => { - const scriptContent = ` -import json -import sys -data = json.load(sys.stdin) -print(json.dumps({"received": data})) -`; - await fs.writeFile( - path.join(scriptsDir, "stdin_test.py"), - scriptContent, - "utf8" - ); - - const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); - const resultJson = await invokeTool(tool, { - scriptName: "stdin_test.py", - input: '{"message":"hello","count":42}', - }); - const result = JSON.parse(resultJson) as PythonResult; - - expect(result.success).toBe(true); - expect(result.exitCode).toBe(0); - - const output = JSON.parse(result.stdout.trim()) as { - received: { message: string; count: number }; - }; - expect(output.received.message).toBe("hello"); - expect(output.received.count).toBe(42); - }); - - it("works with empty input string", async () => { - const scriptContent = 'print("no stdin needed")'; - await fs.writeFile( - path.join(scriptsDir, "no_stdin.py"), - scriptContent, - "utf8" - ); - - const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); - const resultJson = await invokeTool(tool, { - scriptName: "no_stdin.py", - input: "", - }); - const result = JSON.parse(resultJson) as PythonResult; - - expect(result.success).toBe(true); - expect(result.stdout).toContain("no stdin needed"); - }); - it("handles invalid JSON input", async () => { const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); const resultJson = await invokeTool(tool, { @@ -211,35 +117,4 @@ print(json.dumps({"received": data})) expect(result.success).toBe(false); expect(result.error).toBe("Invalid JSON in input parameter"); }); - - it("handles complex nested input objects", async () => { - const scriptContent = ` -import json -import sys -data = json.load(sys.stdin) -print(json.dumps({"features": data["feature_ids"], "seed": data["seed"]})) -`; - await fs.writeFile( - path.join(scriptsDir, "nested_input.py"), - scriptContent, - "utf8" - ); - - const tool = createRunPythonTool({ scriptsDir, logger: mockLogger }); - const resultJson = await invokeTool(tool, { - scriptName: "nested_input.py", - input: - '{"ticker":"SPY","feature_ids":["mom_1m","vol_3m","px_sma50"],"seed":42}', - }); - const result = JSON.parse(resultJson) as PythonResult; - - expect(result.success).toBe(true); - - const output = JSON.parse(result.stdout.trim()) as { - features: string[]; - seed: number; - }; - expect(output.features).toEqual(["mom_1m", "vol_3m", "px_sma50"]); - expect(output.seed).toBe(42); - }); });