mirror of
https://github.com/KhizarImran/backtestingfx.git
synced 2026-11-02 11:36:31 +00:00
fix: validate broker settings, orders, and optimization signals
Reject invalid numeric inputs at Python boundaries before account mutation or grid workers start. Preserve partial-close no-ops and profit-locking stops, and reject invalid native SMA windows without panicking. Refs #16 Codex 🤖, On behalf of Khizar
This commit is contained in:
@@ -9,6 +9,12 @@
|
|||||||
API/platform compatibility policy, and a prioritized FX roadmap.
|
API/platform compatibility policy, and a prioritized FX roadmap.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- Python constructors reject nonfinite or out-of-range broker settings. Order,
|
||||||
|
partial-close, stop-update, closing-price, and equity-price inputs are checked
|
||||||
|
before account mutation. Finite nonpositive partial sizes remain no-ops.
|
||||||
|
- Optimization rejects malformed or nonfinite signals, including direct
|
||||||
|
`run_grid()` calls. Native SMA calls reject invalid sizes and windows before
|
||||||
|
simulation rather than risking a Rust panic.
|
||||||
- SL/TP processing checks the midpoint open first: crossed stops fill at the
|
- SL/TP processing checks the midpoint open first: crossed stops fill at the
|
||||||
adverse open, while crossed targets receive the favorable open, before any
|
adverse open, while crossed targets receive the favorable open, before any
|
||||||
later range touches. Exit spread, commission, and conversion still apply.
|
later range touches. Exit spread, commission, and conversion still apply.
|
||||||
@@ -24,6 +30,11 @@
|
|||||||
the exit-side spread (future exit commission is still charged only at closing).
|
the exit-side spread (future exit commission is still charged only at closing).
|
||||||
|
|
||||||
### Migration
|
### Migration
|
||||||
|
- Python cash, contract size, conversion rate, opening sizes, prices, and supplied
|
||||||
|
SL/TP levels must be positive and finite; spread and commission must be finite
|
||||||
|
and nonnegative. Invalid numeric inputs now raise `ValueError`. Signal arrays
|
||||||
|
must be one-dimensional, finite, and contain one target per bar. See README
|
||||||
|
numeric input validation for partial-close and native SMA rules.
|
||||||
- Gap-bar SL/TP fills now use the opening price instead of the configured level.
|
- Gap-bar SL/TP fills now use the opening price instead of the configured level.
|
||||||
An opening target also takes precedence over a later stop touch. Rerun affected
|
An opening target also takes precedence over a later stop touch. Rerun affected
|
||||||
backtests: PnL, equity, and strategy decisions can change; there is no legacy
|
backtests: PnL, equity, and strategy decisions can change; there is no legacy
|
||||||
|
|||||||
@@ -195,6 +195,46 @@ Backtest(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Numeric input validation
|
||||||
|
|
||||||
|
`Backtest`, `Broker`, and `Engine` constructors reject invalid numeric settings
|
||||||
|
with `ValueError` before simulation:
|
||||||
|
|
||||||
|
| Input | Allowed values |
|
||||||
|
| --- | --- |
|
||||||
|
| `cash` (`initial_cash` on `Broker` and `Engine`) | Finite and greater than zero |
|
||||||
|
| `commission`, `spread` | Finite and nonnegative |
|
||||||
|
| `contract_size`, `quote_to_account` | Finite and greater than zero |
|
||||||
|
|
||||||
|
Python `buy()` and `sell()` require positive finite lot sizes and prices.
|
||||||
|
Supplied stop-loss and take-profit levels, including `update_sl()` values, must
|
||||||
|
also be positive and finite. Levels are not restricted relative to the entry
|
||||||
|
price: a trailing stop can lock in profit, and stops can still be widened.
|
||||||
|
Closing prices and equity mark prices must be positive and finite.
|
||||||
|
Rejected calls leave cash, positions, and position IDs unchanged.
|
||||||
|
|
||||||
|
`close_partial()` rejects nonfinite sizes. Finite zero or negative sizes remain
|
||||||
|
no-ops, and a size at or above the remaining position closes it completely.
|
||||||
|
Unknown position IDs remain no-ops; `update_sl()` returns `False` for them.
|
||||||
|
These behaviors require otherwise valid numeric arguments.
|
||||||
|
|
||||||
|
`optimize()` requires each signal to be a one-dimensional numeric array with
|
||||||
|
one finite target lot size per bar. Positive targets mean long, negative targets
|
||||||
|
mean short, and zero means flat. Use zero during indicator warmup. Both NaN and
|
||||||
|
infinity are rejected. Direct extension `run_grid()` calls also check every
|
||||||
|
signal's length and finiteness before starting worker threads.
|
||||||
|
`Engine.run_native_sma()` requires a positive finite lot size and integer
|
||||||
|
windows satisfying `1 <= fast <= slow`.
|
||||||
|
|
||||||
|
These checks apply at Python entry points, including direct extension calls.
|
||||||
|
Native Rust callers remain responsible for supplying valid inputs to the
|
||||||
|
simulation methods. OHLC data and timestamp validation are tracked separately
|
||||||
|
in [#15](https://github.com/KhizarImran/backtestingfx/issues/15).
|
||||||
|
|
||||||
|
**Migration:** replace invalid inputs that previously passed through, including
|
||||||
|
zero initial cash, nonpositive opening sizes, and infinite signals. Calls now
|
||||||
|
raise an error instead of silently producing invalid account state.
|
||||||
|
|
||||||
### Spread and price conventions
|
### Spread and price conventions
|
||||||
|
|
||||||
OHLC prices are **midpoints** between bid and ask. `spread` is the constant full
|
OHLC prices are **midpoints** between bid and ask. `spread` is the constant full
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,8 @@ priorities or delivered capabilities change.
|
|||||||
- Implemented (unreleased): gap-aware stop/target fills
|
- Implemented (unreleased): gap-aware stop/target fills
|
||||||
([#8](https://github.com/KhizarImran/backtestingfx/issues/8)); see README execution policy.
|
([#8](https://github.com/KhizarImran/backtestingfx/issues/8)); see README execution policy.
|
||||||
- Data and timestamp validation: [#15](https://github.com/KhizarImran/backtestingfx/issues/15).
|
- Data and timestamp validation: [#15](https://github.com/KhizarImran/backtestingfx/issues/15).
|
||||||
- Broker, size, and signal validation: [#16](https://github.com/KhizarImran/backtestingfx/issues/16).
|
- Implemented (unreleased): Python broker, size, and signal validation
|
||||||
|
([#16](https://github.com/KhizarImran/backtestingfx/issues/16)).
|
||||||
- Independent native engine runs: [#18](https://github.com/KhizarImran/backtestingfx/issues/18).
|
- Independent native engine runs: [#18](https://github.com/KhizarImran/backtestingfx/issues/18).
|
||||||
|
|
||||||
## 2. Core adoption (P1)
|
## 2. Core adoption (P1)
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ class Backtest:
|
|||||||
contract_size=100000.0,
|
contract_size=100000.0,
|
||||||
quote_to_account=1.0,
|
quote_to_account=1.0,
|
||||||
):
|
):
|
||||||
|
_rust._validate_config(cash, commission, spread, contract_size, quote_to_account)
|
||||||
self._df = df
|
self._df = df
|
||||||
self._strategy_class = strategy_class
|
self._strategy_class = strategy_class
|
||||||
self._cash = cash
|
self._cash = cash
|
||||||
@@ -218,11 +219,20 @@ class Backtest:
|
|||||||
signals = []
|
signals = []
|
||||||
for combo in combos:
|
for combo in combos:
|
||||||
signal = np.asarray(signal_fn(self._df, **combo), dtype=float)
|
signal = np.asarray(signal_fn(self._df, **combo), dtype=float)
|
||||||
|
if signal.ndim != 1:
|
||||||
|
raise ValueError(f"signal_fn must return a one-dimensional signal for {combo}")
|
||||||
|
if len(signal) != len(self._df):
|
||||||
|
raise ValueError(
|
||||||
|
f"signal_fn returned {len(signal)} values for {combo}, expected "
|
||||||
|
f"{len(self._df)} (one per bar)"
|
||||||
|
)
|
||||||
if np.isnan(signal).any():
|
if np.isnan(signal).any():
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"signal_fn returned NaN for {combo} — indicator warmup should "
|
f"signal_fn returned NaN for {combo} — indicator warmup should "
|
||||||
"produce 0.0 (flat), not NaN"
|
"produce 0.0 (flat), not NaN"
|
||||||
)
|
)
|
||||||
|
if not np.isfinite(signal).all():
|
||||||
|
raise ValueError(f"signal_fn must return finite values for {combo}")
|
||||||
# ponytail: tolist() is the cheap bridge into Rust. It costs one Python
|
# ponytail: tolist() is the cheap bridge into Rust. It costs one Python
|
||||||
# float per bar per combo; swap in the `numpy` crate for a zero-copy
|
# float per bar per combo; swap in the `numpy` crate for a zero-copy
|
||||||
# PyReadonlyArray1 if this ever shows up in a profile.
|
# PyReadonlyArray1 if this ever shows up in a profile.
|
||||||
|
|||||||
+84
-2
@@ -1,4 +1,5 @@
|
|||||||
use crate::types::{Bar, Position, Trade};
|
use crate::types::{Bar, Position, Trade};
|
||||||
|
use crate::validation;
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
#[pyclass]
|
#[pyclass]
|
||||||
@@ -114,9 +115,7 @@ impl Broker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pymethods]
|
|
||||||
impl Broker {
|
impl Broker {
|
||||||
#[new]
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
initial_cash: f64,
|
initial_cash: f64,
|
||||||
commission: f64,
|
commission: f64,
|
||||||
@@ -267,6 +266,89 @@ impl Broker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Python inputs are checked before the native methods can mutate account state.
|
||||||
|
#[pymethods]
|
||||||
|
impl Broker {
|
||||||
|
#[new]
|
||||||
|
fn py_new(
|
||||||
|
initial_cash: f64,
|
||||||
|
commission: f64,
|
||||||
|
spread: f64,
|
||||||
|
contract_size: f64,
|
||||||
|
quote_to_account: f64,
|
||||||
|
) -> PyResult<Self> {
|
||||||
|
validation::config(initial_cash, commission, spread, contract_size, quote_to_account)?;
|
||||||
|
Ok(Self::new(initial_cash, commission, spread, contract_size, quote_to_account))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "positions")]
|
||||||
|
fn py_positions(&self) -> Vec<Position> {
|
||||||
|
self.positions()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "buy")]
|
||||||
|
fn py_buy(
|
||||||
|
&mut self,
|
||||||
|
price: f64,
|
||||||
|
lot_size: f64,
|
||||||
|
timestamp: i64,
|
||||||
|
stop_loss: Option<f64>,
|
||||||
|
take_profit: Option<f64>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
validation::order(price, lot_size, stop_loss, take_profit)?;
|
||||||
|
self.buy(price, lot_size, timestamp, stop_loss, take_profit);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "sell")]
|
||||||
|
fn py_sell(
|
||||||
|
&mut self,
|
||||||
|
price: f64,
|
||||||
|
lot_size: f64,
|
||||||
|
timestamp: i64,
|
||||||
|
stop_loss: Option<f64>,
|
||||||
|
take_profit: Option<f64>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
validation::order(price, lot_size, stop_loss, take_profit)?;
|
||||||
|
self.sell(price, lot_size, timestamp, stop_loss, take_profit);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "close_position")]
|
||||||
|
fn py_close_position(&mut self, id: u64, price: f64, timestamp: i64) -> PyResult<()> {
|
||||||
|
validation::positive("price", price)?;
|
||||||
|
self.close_position(id, price, timestamp);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "close_partial")]
|
||||||
|
fn py_close_partial(&mut self, id: u64, lot_size: f64, price: f64, timestamp: i64) -> PyResult<()> {
|
||||||
|
validation::finite("lot_size", lot_size)?;
|
||||||
|
validation::positive("price", price)?;
|
||||||
|
self.close_partial(id, lot_size, price, timestamp);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "update_sl")]
|
||||||
|
fn py_update_sl(&mut self, id: u64, stop_loss: f64) -> PyResult<bool> {
|
||||||
|
validation::positive("stop_loss", stop_loss)?;
|
||||||
|
Ok(self.update_sl(id, stop_loss))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "close_all")]
|
||||||
|
fn py_close_all(&mut self, price: f64, timestamp: i64) -> PyResult<()> {
|
||||||
|
validation::positive("price", price)?;
|
||||||
|
self.close_all(price, timestamp);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyo3(name = "equity")]
|
||||||
|
fn py_equity(&self, current_price: f64) -> PyResult<f64> {
|
||||||
|
validation::positive("price", current_price)?;
|
||||||
|
Ok(self.equity(current_price))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+24
-4
@@ -2,6 +2,8 @@ use crate::broker::Broker;
|
|||||||
use crate::stats::Stats;
|
use crate::stats::Stats;
|
||||||
use crate::strategy::Strategy;
|
use crate::strategy::Strategy;
|
||||||
use crate::types::Bar;
|
use crate::types::Bar;
|
||||||
|
use crate::validation;
|
||||||
|
use pyo3::exceptions::PyValueError;
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
#[pyclass]
|
#[pyclass]
|
||||||
@@ -68,9 +70,7 @@ impl Strategy for SmaCross {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[pymethods]
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
#[new]
|
|
||||||
pub fn new(
|
pub fn new(
|
||||||
data: Vec<Bar>,
|
data: Vec<Bar>,
|
||||||
initial_cash: f64,
|
initial_cash: f64,
|
||||||
@@ -91,6 +91,22 @@ impl Engine {
|
|||||||
equity_curve: Vec::new(),
|
equity_curve: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pymethods]
|
||||||
|
impl Engine {
|
||||||
|
#[new]
|
||||||
|
fn py_new(
|
||||||
|
data: Vec<Bar>,
|
||||||
|
initial_cash: f64,
|
||||||
|
commission: f64,
|
||||||
|
spread: f64,
|
||||||
|
contract_size: f64,
|
||||||
|
quote_to_account: f64,
|
||||||
|
) -> PyResult<Self> {
|
||||||
|
validation::config(initial_cash, commission, spread, contract_size, quote_to_account)?;
|
||||||
|
Ok(Self::new(data, initial_cash, commission, spread, contract_size, quote_to_account))
|
||||||
|
}
|
||||||
|
|
||||||
#[pyo3(name = "run")]
|
#[pyo3(name = "run")]
|
||||||
pub fn run_py(&mut self, py: Python<'_>, strategy: Py<PyAny>) -> PyResult<Stats> {
|
pub fn run_py(&mut self, py: Python<'_>, strategy: Py<PyAny>) -> PyResult<Stats> {
|
||||||
@@ -135,14 +151,18 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: benchmark hook, runs the SMA strategy entirely in Rust via the native run() path.
|
// ponytail: benchmark hook, runs the SMA strategy entirely in Rust via the native run() path.
|
||||||
pub fn run_native_sma(&mut self, fast: usize, slow: usize, lot_size: f64) -> Stats {
|
pub fn run_native_sma(&mut self, fast: usize, slow: usize, lot_size: f64) -> PyResult<Stats> {
|
||||||
|
validation::positive("lot_size", lot_size)?;
|
||||||
|
if fast == 0 || slow == 0 || fast > slow {
|
||||||
|
return Err(PyValueError::new_err("SMA windows must satisfy 1 <= fast <= slow"));
|
||||||
|
}
|
||||||
let mut strat = SmaCross {
|
let mut strat = SmaCross {
|
||||||
fast,
|
fast,
|
||||||
slow,
|
slow,
|
||||||
lot_size,
|
lot_size,
|
||||||
closes: Vec::new(),
|
closes: Vec::new(),
|
||||||
};
|
};
|
||||||
self.run(&mut strat)
|
Ok(self.run(&mut strat))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod optimise;
|
|||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod strategy;
|
pub mod strategy;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
use pyo3::prelude::*;
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
@@ -18,5 +19,6 @@ fn backtestingfx(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
m.add_class::<types::Position>()?;
|
m.add_class::<types::Position>()?;
|
||||||
m.add_class::<types::Trade>()?;
|
m.add_class::<types::Trade>()?;
|
||||||
m.add_function(wrap_pyfunction!(optimise::run_grid, m)?)?;
|
m.add_function(wrap_pyfunction!(optimise::run_grid, m)?)?;
|
||||||
|
m.add_function(wrap_pyfunction!(validation::config, m)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ pub fn run_grid(
|
|||||||
bars
|
bars
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
for (signal_index, signal) in signals.iter().enumerate() {
|
||||||
|
if let Some(bar_index) = signal.iter().position(|value| !value.is_finite()) {
|
||||||
|
return Err(PyValueError::new_err(format!(
|
||||||
|
"signal {signal_index} at bar {bar_index} must be finite"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let engine: &Engine = &engine;
|
let engine: &Engine = &engine;
|
||||||
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
//! Numeric checks shared by the public Python entry points.
|
||||||
|
|
||||||
|
use pyo3::exceptions::PyValueError;
|
||||||
|
use pyo3::prelude::*;
|
||||||
|
|
||||||
|
pub(crate) fn finite(name: &str, value: f64) -> PyResult<()> {
|
||||||
|
if !value.is_finite() {
|
||||||
|
return Err(PyValueError::new_err(format!("{name} must be finite")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn positive(name: &str, value: f64) -> PyResult<()> {
|
||||||
|
finite(name, value)?;
|
||||||
|
if value <= 0.0 {
|
||||||
|
return Err(PyValueError::new_err(format!("{name} must be greater than zero")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nonnegative(name: &str, value: f64) -> PyResult<()> {
|
||||||
|
finite(name, value)?;
|
||||||
|
if value < 0.0 {
|
||||||
|
return Err(PyValueError::new_err(format!("{name} must be nonnegative")));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[pyfunction(name = "_validate_config")]
|
||||||
|
pub(crate) fn config(
|
||||||
|
cash: f64,
|
||||||
|
commission: f64,
|
||||||
|
spread: f64,
|
||||||
|
contract_size: f64,
|
||||||
|
quote_to_account: f64,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
positive("cash", cash)?;
|
||||||
|
nonnegative("commission", commission)?;
|
||||||
|
nonnegative("spread", spread)?;
|
||||||
|
positive("contract_size", contract_size)?;
|
||||||
|
positive("quote_to_account", quote_to_account)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn order(
|
||||||
|
price: f64,
|
||||||
|
lot_size: f64,
|
||||||
|
stop_loss: Option<f64>,
|
||||||
|
take_profit: Option<f64>,
|
||||||
|
) -> PyResult<()> {
|
||||||
|
positive("price", price)?;
|
||||||
|
positive("lot_size", lot_size)?;
|
||||||
|
if let Some(stop) = stop_loss {
|
||||||
|
positive("stop_loss", stop)?;
|
||||||
|
}
|
||||||
|
if let Some(target) = take_profit {
|
||||||
|
positive("take_profit", target)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import math
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from backtestingfx import Backtest, Bar, Broker, Engine, Strategy
|
||||||
|
from backtestingfx import _backtestingfx as native
|
||||||
|
|
||||||
|
|
||||||
|
def market():
|
||||||
|
return pd.DataFrame(
|
||||||
|
{column: [1.1, 1.1] for column in ("open", "high", "low", "close")},
|
||||||
|
index=pd.date_range("2026-01-01", periods=2, freq="h", tz="UTC"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def broker():
|
||||||
|
return Broker(10_000.0, 7.0, 0.0001, 100_000.0, 1.27)
|
||||||
|
|
||||||
|
|
||||||
|
def engine():
|
||||||
|
return Engine([Bar(i, 1.1, 1.1, 1.1, 1.1, 0.0) for i in range(2)],
|
||||||
|
10_000.0, 7.0, 0.0001, 100_000.0, 1.27)
|
||||||
|
|
||||||
|
|
||||||
|
class NumericValidationTest(unittest.TestCase):
|
||||||
|
def test_configuration_rejected_at_all_python_constructors(self):
|
||||||
|
defaults = [10_000.0, 0.0, 0.0, 100_000.0, 1.0]
|
||||||
|
names = ["cash", "commission", "spread", "contract_size", "quote_to_account"]
|
||||||
|
for index, name in enumerate(names):
|
||||||
|
invalid = [math.nan, math.inf, -math.inf, -1.0]
|
||||||
|
if name not in ("commission", "spread"):
|
||||||
|
invalid.append(0.0)
|
||||||
|
for value in invalid:
|
||||||
|
values = defaults.copy()
|
||||||
|
values[index] = value
|
||||||
|
for label, constructor in (
|
||||||
|
("Broker", lambda: Broker(*values)),
|
||||||
|
("Engine", lambda: Engine([], *values)),
|
||||||
|
("Backtest", lambda: Backtest(market(), **dict(zip(names, values)))),
|
||||||
|
):
|
||||||
|
with self.subTest(name=name, value=value, constructor=label):
|
||||||
|
with self.assertRaisesRegex(ValueError, name):
|
||||||
|
constructor()
|
||||||
|
|
||||||
|
def test_invalid_orders_leave_cash_positions_and_ids_unchanged(self):
|
||||||
|
for method in ("buy", "sell"):
|
||||||
|
for name in ("lot_size", "price", "stop_loss", "take_profit"):
|
||||||
|
for value in (0.0, -1.0, math.nan, math.inf, -math.inf):
|
||||||
|
with self.subTest(method=method, name=name, value=value):
|
||||||
|
account = broker()
|
||||||
|
args = {"price": 1.1, "lot_size": 0.4, "timestamp": 0,
|
||||||
|
"stop_loss": None, "take_profit": None}
|
||||||
|
args[name] = value
|
||||||
|
with self.assertRaisesRegex(ValueError, name):
|
||||||
|
getattr(account, method)(**args)
|
||||||
|
self.assertEqual(account.cash, 10_000.0)
|
||||||
|
self.assertEqual(account.positions(), [])
|
||||||
|
account.buy(1.1, 0.4, 0, None, None)
|
||||||
|
self.assertEqual(account.positions()[0].id, 0)
|
||||||
|
|
||||||
|
def test_invalid_partial_closes_and_stop_updates_preserve_position(self):
|
||||||
|
for value in (math.nan, math.inf, -math.inf):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
account = broker()
|
||||||
|
account.buy(1.1, 1.0, 0, 1.09, None)
|
||||||
|
cash = account.cash
|
||||||
|
with self.assertRaisesRegex(ValueError, "lot_size"):
|
||||||
|
account.close_partial(0, value, 1.1, 1)
|
||||||
|
with self.assertRaisesRegex(ValueError, "stop_loss"):
|
||||||
|
account.update_sl(0, value)
|
||||||
|
self.assertEqual(account.cash, cash)
|
||||||
|
self.assertEqual(account.positions()[0].lot_size, 1.0)
|
||||||
|
self.assertEqual(account.positions()[0].stop_loss, 1.09)
|
||||||
|
|
||||||
|
def test_partial_close_noops_and_profit_trailing_stops_still_work(self):
|
||||||
|
account = broker()
|
||||||
|
account.buy(1.1, 1.0, 0, 1.09, None)
|
||||||
|
for lots in (0.0, -1.0):
|
||||||
|
account.close_partial(0, lots, 1.1, 1)
|
||||||
|
account.close_partial(999, 0.4, 1.1, 1)
|
||||||
|
self.assertFalse(account.update_sl(999, 1.09))
|
||||||
|
self.assertTrue(account.update_sl(0, 1.105))
|
||||||
|
self.assertEqual(account.positions()[0].lot_size, 1.0)
|
||||||
|
self.assertAlmostEqual(account.cash, 9993.0)
|
||||||
|
account.close_partial(0, 2.0, 1.1, 1)
|
||||||
|
self.assertEqual(account.positions(), [])
|
||||||
|
self.assertAlmostEqual(account.cash, 9973.3)
|
||||||
|
|
||||||
|
def test_invalid_close_and_equity_prices_raise_before_mutation(self):
|
||||||
|
for value in (0.0, -1.0, math.nan, math.inf, -math.inf):
|
||||||
|
for method in ("close_all", "close_position", "close_partial", "equity"):
|
||||||
|
with self.subTest(method=method, value=value):
|
||||||
|
account = broker()
|
||||||
|
account.buy(1.1, 1.0, 0, None, None)
|
||||||
|
args = {"close_all": (value, 1), "close_position": (0, value, 1),
|
||||||
|
"close_partial": (0, 0.4, value, 1), "equity": (value,)}
|
||||||
|
with self.assertRaisesRegex(ValueError, "price"):
|
||||||
|
getattr(account, method)(*args[method])
|
||||||
|
self.assertEqual(len(account.positions()), 1)
|
||||||
|
self.assertAlmostEqual(account.cash, 9993.0)
|
||||||
|
|
||||||
|
def test_strategy_order_errors_propagate_as_value_errors(self):
|
||||||
|
class InvalidOrder(Strategy):
|
||||||
|
def next(self):
|
||||||
|
self.buy(math.nan)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "lot_size"):
|
||||||
|
Backtest(market(), InvalidOrder).run()
|
||||||
|
|
||||||
|
def test_optimize_rejects_nonfinite_and_malformed_signals(self):
|
||||||
|
cases = [([0.0, math.inf], "finite"), ([math.nan, 0.0], "NaN"),
|
||||||
|
([0.0, -math.inf], "finite"), ([[0.0], [0.0]], "one-dimensional"),
|
||||||
|
(0.1, "one-dimensional"), ([0.1], "one per bar")]
|
||||||
|
for signal, message in cases:
|
||||||
|
with self.subTest(signal=signal):
|
||||||
|
with self.assertRaisesRegex(ValueError, message):
|
||||||
|
Backtest(market()).optimize(lambda df, unused: signal, unused=[1])
|
||||||
|
|
||||||
|
def test_direct_grid_rejects_nonfinite_signals_in_every_combination(self):
|
||||||
|
for value in (math.nan, math.inf, -math.inf):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
with self.assertRaisesRegex(ValueError, "signal 1.*bar 1.*finite"):
|
||||||
|
native.run_grid(engine(), [[0.0, 0.0], [0.0, value]])
|
||||||
|
with self.assertRaisesRegex(ValueError, "one per bar"):
|
||||||
|
native.run_grid(engine(), [[0.0]])
|
||||||
|
|
||||||
|
def test_signed_and_flat_signals_remain_valid(self):
|
||||||
|
results = Backtest(market()).optimize(
|
||||||
|
lambda df, lots: np.full(len(df), lots), lots=[-0.4, 0.0, 0.4],
|
||||||
|
)
|
||||||
|
self.assertEqual(len(results), 3)
|
||||||
|
for params, stats in results:
|
||||||
|
self.assertAlmostEqual(stats.final_cash, 10_000.0)
|
||||||
|
self.assertEqual(stats.num_trades, 0 if params["lots"] == 0 else 1)
|
||||||
|
|
||||||
|
def test_native_sma_rejects_invalid_sizes_and_windows_without_panicking(self):
|
||||||
|
for lots in (0.0, -1.0, math.nan, math.inf, -math.inf):
|
||||||
|
with self.subTest(lots=lots):
|
||||||
|
with self.assertRaisesRegex(ValueError, "lot_size"):
|
||||||
|
engine().run_native_sma(1, 2, lots)
|
||||||
|
for fast, slow in ((0, 2), (1, 0), (3, 1)):
|
||||||
|
with self.subTest(fast=fast, slow=slow):
|
||||||
|
with self.assertRaisesRegex(ValueError, "fast|slow"):
|
||||||
|
engine().run_native_sma(fast, slow, 0.4)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user