ta-pattern-lib
Version:
Technical Analysis and Backtesting Framework for Node.js
487 lines • 25.3 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.StrategyRunner = void 0;
const lodash_round_1 = __importDefault(require("lodash.round"));
const events_1 = require("events");
const feature_dsl_parser_1 = require("./feature_dsl_parser");
class StrategyRunner extends events_1.EventEmitter {
constructor(candles, strategy, function_registry) {
super();
this.start_time = Date.now();
this.trades = [];
this.candle_decisions = [];
this.candles = candles;
this.strategy = strategy;
this.function_registry = function_registry;
this.capital = strategy.capital;
this.warmup_period = strategy.warmup_period || 100;
this.lookback_period = strategy.lookback_period || 200;
// Validate the strategy configuration
this.evaluateStrategy();
// Initialize strategy state with default values
this.state = {
in_position: false,
entry_index: null,
entry_time: null,
entry_price: null,
position_size: 0,
stop_price: null,
take_profit_price: null,
breakeven_triggered: false,
trailing_stop_active: false,
cooldown_remaining: 0,
side: null,
};
}
/**
* Validates the strategy configuration to ensure it meets minimum requirements
* and has consistent settings.
* @throws Error if the strategy configuration is invalid
*/
evaluateStrategy() {
const strategy = this.strategy;
const errors = [];
// Check required fields
if (!strategy.name) {
errors.push("Strategy name is required");
}
if (strategy.capital <= 0) {
errors.push("Capital must be greater than zero");
}
// Check entry conditions
if (!strategy.entry_long && !strategy.entry_short) {
errors.push("At least one entry condition (long or short) must be defined");
}
// Check risk parameters
if (strategy.risk_per_trade !== undefined && (strategy.risk_per_trade <= 0 || strategy.risk_per_trade > 1)) {
errors.push("Risk per trade must be between 0 and 1");
}
// Check stop loss and take profit expressions
// if (strategy.entry_long && !strategy.stop_loss_expr_long) {
// errors.push("Stop loss expression for long positions is required when long entry is defined");
// }
if (strategy.entry_short && !strategy.stop_loss_expr_short) {
errors.push("Stop loss expression for short positions is required when short entry is defined");
}
// Check for consistent trailing stop configuration
if (strategy.trailing_trigger_expr_long && !strategy.trailing_offset_expr_long) {
errors.push("Trailing offset expression for long positions is required when trailing trigger is defined");
}
if (strategy.trailing_trigger_expr_short && !strategy.trailing_offset_expr_short) {
errors.push("Trailing offset expression for short positions is required when trailing trigger is defined");
}
// Check transaction charges
if (strategy.transaction_charges !== undefined && strategy.transaction_charges < 0) {
errors.push("Transaction charges cannot be negative");
}
// Check cooldown period
if (strategy.cooldown_period !== undefined && strategy.cooldown_period < 0) {
errors.push("Cooldown period cannot be negative");
}
// If any errors were found, throw an exception with all error messages
if (errors.length > 0) {
throw new Error(`Strategy validation failed:\n${errors.join("\n")}`);
}
}
async run() {
try {
for (let i = this.warmup_period; i < this.candles.length; i++) {
// Loop through each candle
const sliced = this.candles.slice(i - this.lookback_period > 0 ? i - this.lookback_period : 0, i + 1); // Get candles up to current index
const candle = this.candles[i]; // Get current candle
const parser = new feature_dsl_parser_1.DSLParser(sliced, this.function_registry); // Create parser with available candles
if (this.state.cooldown_remaining > 0) {
// If in cooldown period
this.state.cooldown_remaining--; // Decrement cooldown counter
}
if (!this.state.in_position) {
// If not currently in a position
this.try_entry(i, candle, parser); // Try to enter a new position
}
else {
// If already in a position
this.try_exit(i, candle, parser); // Try to exit the current position
}
const progress = ((i + 1) / this.candles.length) * 100;
if (progress % 2 === 0) {
this.emit("progress", {
progress: progress,
report: this.get_report(),
});
}
}
}
catch (e) {
console.error(e);
}
return this.trades; // Return completed trades
}
try_entry(index, candle, parser) {
if (this.state.cooldown_remaining > 0)
return; // Skip if in cooldown period
if (this.state.in_position)
return; // Skip if already in a position
let expression = null;
let take_profit_expression = null;
// Evaluate entry conditions using DSL
const long_entry = this.strategy.entry_long ? parser.evaluate(this.strategy.entry_long) : false; // Check long entry condition
const long_expression = parser.get_last_resolved_expression(); // Get last resolved expression
const short_entry = this.strategy.entry_short ? parser.evaluate(this.strategy.entry_short) : false; // Check short entry condition
const short_expression = parser.get_last_resolved_expression(); // Get last resolved expression
let side = null; // Initialize position side
if (long_entry === true) {
side = "long";
expression = long_expression;
} // Set side to long if long entry condition is true
if (short_entry === true) {
side = "short";
expression = short_expression;
} // Set side to short if short entry condition is true
if (!side) {
this.candle_decisions.push({
index,
decision: "IGNORE",
last_traded_price: candle.close,
stop_loss: null,
take_profit: null,
long_expression,
short_expression,
});
return;
} // Exit if no valid entry signal
const entry_price = candle.close; // Use close price as entry price
const context = { entry_price, stop_price: 0, target_price: 0 }; // Create context for DSL evaluation
// Select appropriate stop loss expression based on position direction
const stop_loss_expr = side === "long" ? this.strategy.stop_loss_expr_long : side === "short" ? this.strategy.stop_loss_expr_short : this.strategy.stop_loss_expr_long;
// Calculate stop loss price using DSL if provided
const stop_price = stop_loss_expr ? (0, lodash_round_1.default)(Number(parser.evaluate(stop_loss_expr, context)), 2) : null;
// Calculate take profit price using DSL if provided
context.stop_price = stop_price || 0; // Update context with stop price
// Select appropriate target expression based on position direction
const target_expr = side === "long" ? this.strategy.target_expr_long : side === "short" ? this.strategy.target_expr_short : this.strategy.target_expr_long;
const target_price = target_expr ? (0, lodash_round_1.default)(Number(parser.evaluate(target_expr, context)), 2) : null;
take_profit_expression = parser.get_last_resolved_expression();
context.target_price = target_price || 0; // Update context with target price
// Calculate position sizing
const risk_per_trade = this.strategy.risk_per_trade ?? 0.01; // Get risk per trade or default to 1%
const capital_to_risk = this.capital * risk_per_trade; // Calculate amount of capital to risk
// Calculate stop gap (distance to stop loss)
let stop_gap = stop_price !== null ? Math.abs(entry_price - stop_price) : 1; // Calculate stop gap or use default
stop_gap = stop_gap === 0 ? 0.01 : stop_gap; // Ensure stop gap is not zero
// Calculate position size based on risk and capital constraints
let position_size = Math.floor(capital_to_risk / stop_gap) || Infinity;
// Check capital constraint
const max_position_by_capital = Math.floor(this.capital / entry_price);
position_size = Math.min(position_size, max_position_by_capital);
// Ensure we have a valid position size
if (position_size <= 0) {
console.debug("Insufficient capital for minimum position size");
return;
}
this.candle_decisions.push({
index,
decision: `ENTRY ${side}`,
last_traded_price: candle.close,
stop_loss: stop_price,
take_profit: target_price,
take_profit_expression,
});
this.state = {
in_position: true, // Now in a position
entry_index: index, // Set entry index
entry_time: candle.time, // Set entry time
entry_price, // Set entry price
position_size, // Set position size
stop_price, // Set stop loss price
take_profit_price: target_price, // Set take profit price
breakeven_triggered: false, // Reset breakeven flag
trailing_stop_active: false, // Reset trailing stop flag
cooldown_remaining: 0, // Reset cooldown
side, // Set position side
};
}
try_exit(index, candle, parser) {
const state = this.state; // Get current state
if (!state.in_position)
return; // Exit if not in a position
const is_long = state.side === "long"; // Check if position is long
const is_short = state.side === "short"; // Check if position is short
const current_price = candle.close; // Use close price as current price
let breakeven_expression = null;
let update_sl_expression = null;
let exit_expression = null;
let exit_reason = null; // Initialize exit reason
let decision_made = false; // Flag to track if a decision has been made
// === Stop Loss ===
// Check if stop loss has been hit
if (state.stop_price !== null && ((is_long && candle.low <= state.stop_price) || (is_short && candle.high >= state.stop_price))) {
if (state.breakeven_triggered && state.stop_price === state.entry_price) {
exit_reason = "sl_breakeven"; // Special case: breakeven SL
}
else {
exit_reason = "sl"; // Normal SL
}
}
// === Breakeven ===
// Select appropriate breakeven expression based on position direction
const breakeven_expr = is_long ? this.strategy.breakeven_trigger_expr_long : is_short ? this.strategy.breakeven_trigger_expr_short : null;
if (!state.breakeven_triggered && breakeven_expr) {
// If breakeven not triggered and expression exists
const be_trigger = parser.evaluate(breakeven_expr, {
// Evaluate breakeven trigger condition
entry_price: state.entry_price,
target_price: state.take_profit_price,
stop_loss: state.stop_price,
position_size: state.position_size,
});
breakeven_expression = parser.get_last_resolved_expression();
if (be_trigger === true) {
this.candle_decisions.push({
index,
decision: `UPDATED_SL_TO_BREAKEVEN`,
last_traded_price: candle.close,
stop_loss: state.entry_price,
take_profit: state.take_profit_price,
update_sl_expression,
breakeven_expression,
});
decision_made = true; // Set decision flag
// If breakeven condition met
this.state.breakeven_triggered = true; // Set breakeven flag
this.state.stop_price = state.entry_price; // Move stop to entry price
}
}
// === Trailing Stop Activation ===
// Select appropriate trailing trigger expression based on position direction
const trailing_trigger_expr = is_long ? this.strategy.trailing_trigger_expr_long : is_short ? this.strategy.trailing_trigger_expr_short : null;
if (!state.trailing_stop_active && trailing_trigger_expr) {
// If trailing stop not active and expression exists
const trailing_triggered = parser.evaluate(trailing_trigger_expr, {
// Evaluate trailing stop trigger condition
entry_price: state.entry_price,
target_price: state.take_profit_price,
stop_loss: state.stop_price,
position_size: state.position_size,
});
update_sl_expression = parser.get_last_resolved_expression();
if (trailing_triggered === true) {
// If trailing stop condition met
this.state.trailing_stop_active = true; // Activate trailing stop
}
}
// === Trailing Stop Update ===
// Select appropriate trailing offset expression based on position direction
const trailing_offset_expr = is_long ? this.strategy.trailing_offset_expr_long : is_short ? this.strategy.trailing_offset_expr_short : null;
if (state.trailing_stop_active && trailing_offset_expr) {
// If trailing stop active and offset expression exists
const trailing_offset = Number(
// Calculate trailing stop offset
parser.evaluate(trailing_offset_expr, {
entry_price: state.entry_price,
target_price: state.take_profit_price,
stop_loss: state.stop_price,
position_size: state.position_size,
}));
// Calculate new trailing stop level based on position direction
const trailing_sl = is_long ? (0, lodash_round_1.default)(current_price - trailing_offset, 2) : (0, lodash_round_1.default)(current_price + trailing_offset, 2);
// Update stop price for long positions if new stop is higher
if (is_long && (state.stop_price === null || trailing_sl > state.stop_price)) {
this.candle_decisions.push({
index,
decision: `UPDATED_SL: ${trailing_sl}`,
last_traded_price: candle.close,
stop_loss: trailing_sl,
take_profit: state.take_profit_price,
update_sl_expression,
breakeven_expression,
});
decision_made = true; // Set decision flag
this.state.stop_price = trailing_sl;
}
// Update stop price for short positions if new stop is lower
if (is_short && (state.stop_price === null || trailing_sl < state.stop_price)) {
this.candle_decisions.push({
index,
decision: `UPDATED_SL: ${trailing_sl}`,
last_traded_price: candle.close,
stop_loss: trailing_sl,
take_profit: state.take_profit_price,
update_sl_expression,
breakeven_expression,
});
decision_made = true; // Set decision flag
this.state.stop_price = trailing_sl;
}
}
// === Take Profit ===
// Check if take profit has been hit
if (exit_reason === null && state.take_profit_price !== null && ((is_long && candle.high >= state.take_profit_price) || (is_short && candle.low <= state.take_profit_price))) {
exit_reason = "tp"; // Set exit reason to take profit
}
// === Exit DSL ===
if (exit_reason === null) {
// If no exit reason yet
// Get appropriate exit rule based on position side
const rule = is_long ? this.strategy.exit_long : is_short ? this.strategy.exit_short : null;
if (rule) {
// If exit rule exists
const should_exit = parser.evaluate(rule, {
// Evaluate exit condition
entry_price: state.entry_price,
target_price: state.take_profit_price,
stop_loss: state.stop_price,
position_size: state.position_size,
});
exit_expression = parser.get_last_resolved_expression();
if (should_exit === true) {
// If exit condition met
exit_reason = "exit_condition"; // Set exit reason to exit condition
}
}
}
if (!exit_reason && !decision_made) {
this.candle_decisions.push({
index,
decision: `HOLD`,
last_traded_price: candle.close,
stop_loss: state.stop_price,
take_profit: state.take_profit_price,
update_sl_expression,
breakeven_expression,
exit_expression,
});
}
if (!exit_reason) {
return;
} // Exit if no exit reason found
// === Finalize Trade ===
const qty = state.position_size; // Get position size
const entry_price = state.entry_price; // Get entry price
const exit_price = current_price; // Use current price as exit price
// Calculate profit/loss
const gross_pnl = is_long ? (exit_price - entry_price) * qty : (entry_price - exit_price) * qty;
// Calculate total transaction value
const turnover = (entry_price + exit_price) * qty;
// Calculate transaction charges
const charges = (this.strategy.transaction_charges ?? 0) * turnover;
// Calculate net profit/loss
const pnl = (0, lodash_round_1.default)(gross_pnl - charges, 2);
// Calculate percentage profit/loss
const pnl_percent = (0, lodash_round_1.default)(((exit_price - entry_price) / entry_price) * (is_long ? 1 : -1) * 100, 3);
this.candle_decisions.push({
index,
decision: `EXITED: ${exit_reason}`,
last_traded_price: candle.close,
stop_loss: state.stop_price,
take_profit: state.take_profit_price,
update_sl_expression,
breakeven_expression,
exit_expression,
});
// Add completed trade to trades array
this.trades.push({
entry_index: state.entry_index,
exit_index: index,
entry_time: state.entry_time,
exit_time: candle.time,
entry_price,
exit_price,
position_size: qty,
side: state.side,
pnl,
pnl_percent,
reason: exit_reason,
stop_price: state.stop_price ?? undefined,
take_profit_price: state.take_profit_price ?? undefined,
trailing_triggered: state.trailing_stop_active,
breakeven_triggered: state.breakeven_triggered,
});
// === Capital Update & Reset State ===
this.capital += pnl; // Update capital with trade profit/loss
// Reset state for next trade
this.state = {
in_position: false, // No longer in a position
entry_index: null, // Clear entry index
entry_time: null, // Clear entry time
entry_price: null, // Clear entry price
position_size: 0, // Clear position size
stop_price: null, // Clear stop price
take_profit_price: null, // Clear take profit price
breakeven_triggered: false, // Reset breakeven flag
trailing_stop_active: false, // Reset trailing stop flag
cooldown_remaining: this.strategy.cooldown_period ?? 0, // Set cooldown period
side: null, // Clear position side
last_exit_price: exit_price, // Store last exit price
last_exit_index: index, // Store last exit index
last_exit_reason: exit_reason, // Store last exit reason
};
}
get_report() {
const total_trades = this.trades.length; // Get total number of trades
const winning_trades = this.trades.filter((t) => t.pnl > 0); // Filter winning trades
const losing_trades = this.trades.filter((t) => t.pnl <= 0); // Filter losing trades
const total_pnl = this.trades.reduce((acc, t) => acc + t.pnl, 0); // Calculate total profit/loss
const total_pnl_percent = this.trades.reduce((acc, t) => acc + t.pnl_percent, 0); // Calculate total percentage profit/loss
const avg_pnl = total_trades > 0 ? total_pnl / total_trades : 0; // Calculate average profit/loss per trade
const avg_pnl_percent = total_trades > 0 ? total_pnl_percent / total_trades : 0; // Calculate average percentage profit/loss per trade
const win_rate = total_trades > 0 ? (winning_trades.length / total_trades) * 100 : 0; // Calculate win rate
const avg_win = winning_trades.length > 0 ? winning_trades.reduce((acc, t) => acc + t.pnl, 0) / winning_trades.length : 0; // Calculate average winning trade
const avg_loss = losing_trades.length > 0 ? losing_trades.reduce((acc, t) => acc + t.pnl, 0) / losing_trades.length : 0; // Calculate average losing trade
const avg_hold = total_trades > 0 ? this.trades.reduce((acc, t) => acc + (t.exit_index - t.entry_index), 0) / total_trades : 0; // Calculate average holding period
// Calculate maximum drawdown
let max_drawdown = 0;
let peak_capital = this.strategy.capital;
let running_capital = this.strategy.capital;
for (const trade of this.trades) {
running_capital += trade.pnl;
if (running_capital > peak_capital) {
peak_capital = running_capital;
}
else {
const drawdown = (peak_capital - running_capital) / peak_capital * 100;
if (drawdown > max_drawdown) {
max_drawdown = drawdown;
}
}
}
// Calculate Sharpe ratio
// Using daily returns assumption and risk-free rate of 6.3%
let sharpe_ratio = 0;
if (total_trades > 1) {
const returns = this.trades.map(t => t.pnl_percent / 100); // Convert percentage to decimal
const mean_return = returns.reduce((sum, r) => sum + r, 0) / returns.length;
const risk_free_rate = 0.063; // 6.3% annual risk-free rate
// Calculate standard deviation of returns
const variance = returns.reduce((sum, r) => sum + Math.pow(r - mean_return, 2), 0) / (returns.length - 1);
const std_dev = Math.sqrt(variance);
// Annualized Sharpe ratio (assuming daily returns)
sharpe_ratio = std_dev !== 0 ? ((mean_return - risk_free_rate / 252) / std_dev) * Math.sqrt(252) : 0;
}
// Return comprehensive strategy report
return {
metric: {
total_time_taken: Date.now() - this.start_time,
strategy: this.strategy.name,
capital_start: this.strategy.capital,
capital_end: this.capital,
total_trades,
win_rate: (0, lodash_round_1.default)(Number(win_rate), 2),
avg_pnl: (0, lodash_round_1.default)(Number(avg_pnl), 2),
avg_pnl_percent: (0, lodash_round_1.default)(Number(avg_pnl_percent), 2),
avg_win: (0, lodash_round_1.default)(Number(avg_win), 2),
avg_loss: (0, lodash_round_1.default)(Number(avg_loss), 2),
avg_hold: (0, lodash_round_1.default)(Number(avg_hold), 2),
total_profit: (0, lodash_round_1.default)(Number(total_pnl), 2),
max_drawdown: (0, lodash_round_1.default)(Number(max_drawdown), 2),
sharpe_ratio: (0, lodash_round_1.default)(Number(sharpe_ratio), 2),
},
trades: this.trades,
candle_decisions: this.candle_decisions,
};
}
}
exports.StrategyRunner = StrategyRunner;
//# sourceMappingURL=feature_strategy_runner.js.map