A comprehensive stock trading backtesting platform with Kotlin/Spring Boot backend (Udgaard) and Nuxt.js frontend (Asgaard).
- Overview
- Quick Start
- Architecture
- Backtesting Features
- How Backtesting Works
- API Reference
- Strategy System
- Development
- Troubleshooting
The backtesting system allows you to test trading strategies against historical stock data to evaluate their performance. It supports:
- Multiple entry/exit strategies (predefined and custom)
- Position limits with intelligent stock ranking
- Underlying asset support (e.g., trade TQQQ using QQQ signals)
- Global cooldown periods to prevent overtrading
- Comprehensive performance metrics (win rate, edge, profit factor, drawdown)
- Monte Carlo simulation for statistical validation
- Sector and stock performance analysis
- Time-based performance breakdown (yearly, quarterly, monthly)
- Exit reason analysis
- Edge consistency scoring
- Historical stock data analysis with technical indicators (EMA, ATR, Donchian channels)
- Dynamic strategy system with DSL-based strategy creation
- Market and sector breadth analysis
- Portfolio management with live trade tracking (stocks and options)
- MCP (Model Context Protocol) server for Claude AI integration
Option 1: Via API (POST)
curl -X POST http://localhost:8080/udgaard/api/backtest \
-H "Content-Type: application/json" \
-d '{
"stockSymbols": ["AAPL", "GOOGL"],
"entryStrategy": {"type": "custom", "conditions": [{"type": "marketUptrend"}]},
"exitStrategy": {"type": "custom", "conditions": [{"type": "stopLoss", "parameters": {"atrMultiplier": 2.5}}]},
"startDate": "2020-01-01",
"endDate": "2025-11-22",
"maxPositions": 3,
"cooldownDays": 10
}'Option 2: Via UI
- Start the backend:
cd udgaard && docker compose up -d postgres && ./gradlew bootRun - Start the frontend:
cd asgaard && pnpm dev - Open http://localhost:3000
- Navigate to Backtesting page and configure options
- View results with charts and metrics
┌─────────────────────────────────────────────────────────────┐
│ Frontend (Asgaard) │
│ - Nuxt 4.1.2 + TypeScript + Vue 3 │
│ - NuxtUI 4.0.1 + ApexCharts + Unovis │
│ - Config modal, results display, Monte Carlo UI │
└────────────────────────┬────────────────────────────────────┘
↓ HTTP POST
┌─────────────────────────────────────────────────────────────┐
│ Backend (Udgaard) │
│ - Kotlin 2.3.0 + Spring Boot 3.5.0 │
│ - PostgreSQL 17 + jOOQ + Flyway │
│ - BacktestController → BacktestService → Strategy System │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Data Layer │
│ - PostgreSQL 17: Stock quotes, symbols, market breadth │
│ - AlphaVantage: Primary data source (adjusted OHLCV, ATR) │
│ - Technical indicators calculated locally (EMA, Donchian) │
└─────────────────────────────────────────────────────────────┘
Backend:
- Kotlin 2.3.0, Spring Boot 3.5.0
- PostgreSQL 17 (Docker Compose for local dev)
- jOOQ 3.19.23 (type-safe SQL), Flyway (migrations)
- Caffeine (caching), Spring AI MCP Server (Claude integration)
- detekt 2.0.0-alpha.2 (static analysis), ktlint 1.5.0 (code style)
Frontend:
- Nuxt 4.1.2, TypeScript 5.9.3, Vue 3 Composition API
- NuxtUI 4.0.1, ApexCharts 5.3.5, Unovis 1.6.1
Simulate real-world capital constraints:
- Set maximum positions per day (e.g., max 3 stocks)
- Rank candidates using multiple algorithms
- Track missed opportunities
Rankers Available:
Adaptive: Market condition-based ranking (default)Composite: Multi-factor combinationVolatility: ATR-based volatility rankingDistanceFrom10Ema: Value zone proximitySectorStrength: Sector strength rankingRandom: Random selection
Trade leveraged ETFs with cleaner signals:
Example: Trade TQQQ (3x leveraged QQQ) using QQQ signals
- Entry/exit signals from QQQ (less noise, cleaner trends)
- Actual P&L from TQQQ prices
Configuration:
{
"stockSymbols": ["TQQQ"],
"useUnderlyingAssets": true,
"customUnderlyingMap": {
"TQQQ": "QQQ"
}
}Auto-detection also available (TQQQ → QQQ, SOXL → SMH, UPRO → SPY).
Prevent overtrading by enforcing waiting periods:
- Measured in trading days (not calendar days)
- Applies after ANY exit (global, not per-stock)
BacktestReport includes:
- Win/loss statistics (count, rate, amounts, percentages)
- Edge (expected % gain per trade)
- Profit factor (gross profit / gross loss)
- Maximum drawdown
- Edge consistency score (how stable the edge is across years)
- Time-based stats (yearly, quarterly, monthly breakdown)
- Exit reason analysis with per-reason and per-year stats
- Sector performance breakdown
- Stock-level performance breakdown
- ATR drawdown statistics for winning trades
- Market condition averages at trade entry
- Complete trade list with entry/exit details
- Missed opportunity tracking
Edge Calculation:
Edge = (AvgWinPercent × WinRate) - ((1 - WinRate) × AvgLossPercent)
Calculate optimal position sizes based on account equity and risk parameters:
- Fixed Fractional: Risk a fixed percentage of equity per trade (e.g., 1% risk)
- Percent Risk: Size positions based on ATR-derived stop distance
Configuration:
{
"positionSizing": {
"method": "FIXED_FRACTIONAL",
"riskPercentage": 1.0,
"accountEquity": 100000.0
}
}Model realistic execution lag (e.g., seeing signal after market close, entering next day):
{
"entryDelayDays": 1
}- Signal fires on Day 0, actual entry at Day 0+N's close price
- Entry conditions are not re-checked on the delayed day — once a signal fires, the trade is queued and entered at the delayed date's price. This matches real-time execution where you commit to the trade on signal day
- Skips entry if delayed day has no data
Validate strategy edge statistically:
- Trade Shuffling: Same trades, different order
- Bootstrap Resampling: Random sampling with replacement
The backtest simulates trading decisions chronologically to prevent look-ahead bias:
- Build a sorted list of all trading dates in the range
- For each date:
- Check exits for open positions
- Find all stocks meeting entry criteria
- Rank candidates using selected ranker
- Apply position limit (take top N)
- Track missed trades (qualified but couldn't enter)
- Apply cooldown after exits
- Compile results into BacktestReport
- No Look-Ahead Bias: Entry decisions made with data available up to current date only
- Realistic Fills: Assumes fills at close prices
- Chronological Order: Dates processed in sequence
- Dual Stock Support: Trade one symbol, evaluate strategy on another
- Cooldown Enforcement: Tracks last exit date, blocks entries during cooldown
Run a backtest with full configuration.
Request:
{
"stockSymbols": ["AAPL", "GOOGL"],
"assetTypes": ["STOCK"],
"entryStrategy": {
"type": "custom",
"conditions": [
{"type": "marketUptrend"},
{"type": "uptrend"}
]
},
"exitStrategy": {
"type": "custom",
"conditions": [
{"type": "stopLoss", "parameters": {"atrMultiplier": 0.5}},
{"type": "profitTarget", "parameters": {"atrMultiplier": 3.0}}
]
},
"startDate": "2021-01-01",
"endDate": "2025-11-22",
"maxPositions": 3,
"ranker": "Adaptive",
"useUnderlyingAssets": true,
"customUnderlyingMap": {"TQQQ": "QQQ"},
"cooldownDays": 10
}Parameters:
stockSymbols(optional): List of symbols to testassetTypes(optional): Filter by asset type (STOCK, ETF, LEVERAGED_ETF, INDEX, BOND_ETF, COMMODITY_ETF)entryStrategy: Predefined name or custom conditionsexitStrategy: Predefined name or custom conditionsstartDate/endDate(optional): Date rangemaxPositions(optional): Max concurrent positionsranker(optional): Ranking method (default: "Adaptive")useUnderlyingAssets(optional): Use underlying for signals (default: true)cooldownDays(optional): Global cooldown in trading days (default: 0)entryDelayDays(optional): Delay entry by N trading days (default: 0)positionSizing(optional): Position sizing config (method,riskPercentage,accountEquity)
Response:
{
"winningTrades": [...],
"losingTrades": [...],
"missedTrades": [...],
"winRate": 0.68,
"edge": 7.16,
"profitFactor": 2.1,
"averageWinPercent": 12.5,
"averageLossPercent": 4.2,
"totalTrades": 50,
"timeBasedStats": {...},
"exitReasonAnalysis": {...},
"sectorPerformance": [...],
"stockPerformance": [...],
"edgeConsistencyScore": {...}
}Get available predefined strategies.
Get available rankers with rich metadata (mirrors /api/backtest/conditions shape).
Response:
[
{
"type": "Adaptive",
"displayName": "Adaptive",
"description": "Switches between Volatility and DistanceFrom10Ema based on the prevailing market regime.",
"parameters": [],
"category": "Score-Based",
"usesRandomTieBreaks": true
},
{
"type": "SectorEdge",
"displayName": "Sector Edge",
"description": "Ranks stocks by user-supplied sector priority order (highest priority sector first).",
"parameters": [
{ "name": "sectorRanking", "displayName": "Sector Ranking", "type": "stringList", "defaultValue": null }
],
"category": "Sector-Priority",
"usesRandomTieBreaks": false
}
]The full catalog has 9 rankers across three category values (Score-Based, Sector-Priority, Random). Use the parameters array to determine which rankerConfig keys are required.
Get available conditions for custom strategies, including parameter metadata.
Stocks: GET /api/stocks, GET /api/stocks/{symbol}, POST /api/stocks/refresh
Market Breadth: GET /api/breadth, GET /api/breadth/{symbol}, POST /api/breadth/refresh
Portfolio: GET/POST /api/portfolio, GET/DELETE /api/portfolio/{id}, trade management endpoints
Monte Carlo: POST /api/monte-carlo/run
Data Management: POST /api/data/import, POST /api/cache/evict, POST /api/cache/evict-all
Strategies are auto-discovered using the @RegisteredStrategy annotation:
@RegisteredStrategy(name = "MyEntryStrategy", type = StrategyType.ENTRY)
class MyEntryStrategy : DetailedEntryStrategy {
private val compositeStrategy = entryStrategy {
marketUptrend()
sectorUptrend()
uptrend()
priceAbove(20)
}
override fun test(stock: Stock, quote: StockQuote): Boolean {
return compositeStrategy.test(stock, quote)
}
}Build strategies declaratively:
// Entry Strategy
val myEntry = entryStrategy {
marketInUptrend()
sectorInUptrend()
uptrend()
priceAboveEma(20)
marketBreadthAbove(50.0)
}
// Exit Strategy
val myExit = exitStrategy {
stopLoss(0.5) // 0.5 ATR
profitTarget(3.0) // 3.0 ATR
priceBelowEma(10)
emaCross()
}No predefined entry or exit strategies are currently registered. All prior strategies were either invalidated (VCP — order-block lookahead bug), REJECTED via the v4 firewall, or deprecated (mean-reversion-on-pullback class). Use the custom-strategy request shape ({type: "custom", conditions: [...]}) until new candidates land. See knowledge/wiki/overview.md for the state of the search and in-flight candidates.
Entry Conditions (32):
- Market: MarketUptrend, MarketBreadthAbove, MarketBreadthRecovering, MarketBreadthEmaAlignment, MarketBreadthTrending, MarketBreadthNearDonchianLow, SpyPriceUptrend
- Sector: SectorUptrend, SectorBreadthGreaterThanMarket, SectorBreadthAbove, SectorBreadthAccelerating, SectorBreadthEmaAlignment
- Stock Trend: Uptrend, EmaAlignment, EmaBullishCross, PriceAboveEma, EmaSpread, PriceNearDonchianHigh, BullishCandle
- Value Zone: ValueZone, ConsecutiveHigherHighsInValueZone, BelowOrderBlock, AboveBearishOrderBlock
- Order Block: NotInOrderBlock, OrderBlockRejection
- Risk: ADXRange, ATRExpanding, MinimumPrice, VolumeAboveAverage, PriceAbovePreviousLow
- Earnings: DaysSinceEarnings, NoEarningsWithinDays
Exit Conditions (13):
- StopLoss, ProfitTarget, ATRTrailingStopLoss
- PriceBelowEma, PriceBelowEmaForDays, PriceBelowEmaMinusAtr
- EmaCross, BelowPreviousDayLow
- MarketAndSectorDowntrend, MarketBreadthDeteriorating, SectorBreadthBelow
- BearishOrderBlock, BeforeEarnings
Prerequisites:
- Java 21+
- Docker (for PostgreSQL)
- Gradle (included via wrapper)
First-Time Setup:
cd udgaard
# Create secure.properties for API credentials
touch src/main/resources/secure.properties
# Add your AlphaVantage API key (optional, can also be set via Settings UI)
# Start PostgreSQL
docker compose up -d postgres
# Initialize database and build
./gradlew initDatabase # Creates schema via Flyway migrations
./gradlew build # Generates jOOQ code, runs tests, builds JAR
# Start the application
./gradlew bootRunRegular Development:
# Start PostgreSQL (if not already running)
docker compose up -d postgres
# Run application
./gradlew bootRunBackend runs on: http://localhost:8080/udgaard
cd asgaard
pnpm install
pnpm devFrontend runs on: http://localhost:3000
Backend:
cd udgaard
./gradlew testFrontend:
cd asgaard
pnpm typecheck
pnpm lintKotlin code style (ktlint):
cd udgaard
./gradlew ktlintCheck # Check
./gradlew ktlintFormat # Auto-fixStatic analysis (detekt):
cd udgaard
./gradlew detekt # Run analysis
./gradlew detektBaseline # Regenerate baselineSymptoms: Request times out
Solutions:
- Reduce stock count or date range
- The backend timeout is 30 minutes (
spring.mvc.async.request-timeout=1800000) - The frontend timeout is 10 minutes
Error: Missing underlying asset data for: QQQ
Solution: Load the underlying asset data first via the Data Manager page or API.
Possible Causes:
- Strategy too strict: No stocks meet all entry conditions
- Date range mismatch: No data in the specified range
- All in cooldown: Global cooldown too long
Solution:
# Ensure PostgreSQL is running
cd udgaard
docker compose up -d postgres
# Check container status
docker compose ps
# If needed, recreate container
docker compose down && docker compose up -d postgresSolution:
cd udgaard
# Ensure PostgreSQL is running (required for jOOQ code generation)
docker compose up -d postgres
# Clean rebuild
./gradlew clean buildStock data is cached using Caffeine:
- TTL: 30 minutes
- Max entries: 1,000 per cache
- 500 stocks: ~2GB heap, ~29 min runtime
- ~4MB per stock in memory
- HTTP timeout: 30 minutes (main bottleneck for large batches)
trading/
├── udgaard/ # Backend (Kotlin/Spring Boot)
│ ├── src/main/kotlin/com/skrymer/udgaard/
│ │ ├── backtesting/ # Backtesting engine
│ │ │ ├── controller/ # REST controllers
│ │ │ ├── dto/ # Request/response DTOs
│ │ │ ├── model/ # BacktestReport, Trade, metrics
│ │ │ ├── service/ # BacktestService, DynamicStrategyBuilder
│ │ │ └── strategy/ # Entry/exit strategies and conditions
│ │ ├── data/ # Data layer
│ │ │ ├── controller/ # Stock, Breadth, DataManagement controllers
│ │ │ ├── integration/ # AlphaVantage client
│ │ │ ├── model/ # Stock, StockQuote, MarketBreadth
│ │ │ ├── repository/ # jOOQ repositories
│ │ │ └── service/ # StockService, BreadthService, etc.
│ │ ├── portfolio/ # Portfolio domain
│ │ │ ├── controller/ # Portfolio, Position, Option controllers
│ │ │ ├── integration/ # Broker adapters, IBKR, options
│ │ │ ├── service/ # PortfolioService, PositionService, etc.
│ │ │ └── mapper/ # Entity/DTO mappers
│ │ └── mcp/ # MCP server for Claude AI
│ ├── src/main/resources/
│ │ ├── db/migration/ # Flyway SQL migrations
│ │ └── application.properties
│ ├── build.gradle
│ ├── detekt.yml # Static analysis config
│ └── detekt-baseline.xml # Baseline for existing violations
│
├── asgaard/ # Frontend (Nuxt.js)
│ ├── app/
│ │ ├── components/ # Vue components
│ │ │ ├── backtesting/ # Backtest config, results, charts
│ │ │ ├── portfolio/ # Portfolio management
│ │ │ ├── charts/ # Chart components
│ │ │ ├── data-management/ # Data refresh and stats
│ │ │ └── strategy/ # Strategy builder
│ │ ├── pages/ # File-based routing
│ │ │ ├── index.vue # Dashboard
│ │ │ ├── backtesting.vue # Backtesting UI
│ │ │ ├── portfolio.vue # Portfolio management
│ │ │ ├── stock-data.vue # Stock data viewer
│ │ │ ├── data-manager.vue # Data management
│ │ │ ├── settings.vue # Application settings
│ │ │ └── app-metrics.vue # App metrics
│ │ └── types/ # TypeScript definitions
│ ├── nuxt.config.ts
│ └── package.json
│
├── CLAUDE.md # Claude AI context
└── README.md # This file
- Nuxt Documentation
- NuxtUI Documentation
- Spring Boot Documentation
- Kotlin Documentation
- Model Context Protocol
Built with Claude Code
Co-Authored-By: Claude noreply@anthropic.com