mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-08-02 18:07:43 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ce6f6ec6d | |||
| d17d424ee9 | |||
| 5d8e53d208 | |||
| 7880a9315a | |||
| 17bba1a920 | |||
| 360df4083a | |||
| e2c2fefe9a | |||
| 32f7d66e07 | |||
| 4d6ef04411 |
@@ -14,7 +14,7 @@ jobs:
|
|||||||
security:
|
security:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Run Bandit (Security Scan)
|
- name: Run Bandit (Security Scan)
|
||||||
uses: PyCQA/bandit-action@v1
|
uses: PyCQA/bandit-action@v1
|
||||||
@@ -25,9 +25,9 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# Checkout the repository to the GitHub Actions runner
|
# Checkout the repository to the GitHub Actions runner
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
|
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
|
||||||
- name: Run Codacy Analysis CLI
|
- name: Run Codacy Analysis CLI
|
||||||
uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b
|
uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08
|
||||||
env:
|
env:
|
||||||
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
|
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
name: Validate Commit Messages
|
name: Validate Commit Messages
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload docs artifact
|
- name: Upload docs artifact
|
||||||
if: github.ref == 'refs/heads/main'
|
if: github.ref == 'refs/heads/main'
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v5
|
||||||
with:
|
with:
|
||||||
path: docs/_build/html
|
path: docs/_build/html
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ jobs:
|
|||||||
python-version: ["3.10", "3.11"]
|
python-version: ["3.10", "3.11"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
@@ -49,9 +49,9 @@ jobs:
|
|||||||
name: Dependency Audit
|
name: Dependency Audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
cache: "pip"
|
cache: "pip"
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "1.2.2"
|
".": "1.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.3.0](https://github.com/TPTBusiness/Predix/compare/v1.2.2...v1.3.0) (2026-04-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **backtest:** add rolling walk-forward validation and Monte Carlo trade permutation test ([637a94c](https://github.com/TPTBusiness/Predix/commit/637a94c1d987da763869f4f9b73372a3f37d873c))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **security:** resolve all 30 Bandit security alerts (B301, B614, B104) ([ce5983d](https://github.com/TPTBusiness/Predix/commit/ce5983d9d59c4c34341fb1ec749e44bbcfc4a1c4))
|
||||||
|
|
||||||
## [1.2.2](https://github.com/TPTBusiness/Predix/compare/v1.2.1...v1.2.2) (2026-04-19)
|
## [1.2.2](https://github.com/TPTBusiness/Predix/compare/v1.2.1...v1.2.2) (2026-04-19)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@ ignore_missing_imports = true
|
|||||||
module = "llama"
|
module = "llama"
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-l -s --durations=0"
|
addopts = "-l -s --durations=0 -m 'not slow'"
|
||||||
log_cli = true
|
log_cli = true
|
||||||
log_cli_level = "info"
|
log_cli_level = "info"
|
||||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|||||||
@@ -11,16 +11,23 @@ from .vbt_backtest import (
|
|||||||
FTMO_MAX_LEVERAGE,
|
FTMO_MAX_LEVERAGE,
|
||||||
FTMO_RISK_PER_TRADE,
|
FTMO_RISK_PER_TRADE,
|
||||||
OOS_START_DEFAULT,
|
OOS_START_DEFAULT,
|
||||||
|
WF_IS_YEARS,
|
||||||
|
WF_OOS_YEARS,
|
||||||
|
WF_STEP_YEARS,
|
||||||
backtest_from_forward_returns,
|
backtest_from_forward_returns,
|
||||||
backtest_signal,
|
backtest_signal,
|
||||||
backtest_signal_ftmo,
|
backtest_signal_ftmo,
|
||||||
|
monte_carlo_trade_pvalue,
|
||||||
|
walk_forward_rolling,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
|
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
|
||||||
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
|
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
|
||||||
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
|
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
|
||||||
|
'monte_carlo_trade_pvalue', 'walk_forward_rolling',
|
||||||
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
|
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
|
||||||
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
|
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
|
||||||
'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT',
|
'FTMO_MAX_LEVERAGE', 'FTMO_RISK_PER_TRADE', 'OOS_START_DEFAULT',
|
||||||
|
'WF_IS_YEARS', 'WF_OOS_YEARS', 'WF_STEP_YEARS',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -342,6 +342,128 @@ def _apply_ftmo_mask(
|
|||||||
|
|
||||||
OOS_START_DEFAULT = "2024-01-01"
|
OOS_START_DEFAULT = "2024-01-01"
|
||||||
|
|
||||||
|
# Rolling walk-forward default windows (IS years, OOS years, step years)
|
||||||
|
WF_IS_YEARS = 3
|
||||||
|
WF_OOS_YEARS = 1
|
||||||
|
WF_STEP_YEARS = 1
|
||||||
|
|
||||||
|
|
||||||
|
def monte_carlo_trade_pvalue(
|
||||||
|
trade_pnl: pd.Series,
|
||||||
|
n_permutations: int = 1000,
|
||||||
|
seed: int = 0,
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Monte Carlo permutation test on trade-level P&L.
|
||||||
|
|
||||||
|
Shuffles the order of trade returns ``n_permutations`` times and computes
|
||||||
|
the fraction of runs whose total return is >= the real total return.
|
||||||
|
|
||||||
|
p < 0.05 → strategy has a statistically significant edge (real return
|
||||||
|
beats 95% of random sequences with the same set of trades).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
trade_pnl : pd.Series
|
||||||
|
Per-trade net returns (output of ``_compute_trade_pnl``).
|
||||||
|
n_permutations : int
|
||||||
|
Number of random permutations (default 1000).
|
||||||
|
seed : int
|
||||||
|
RNG seed for reproducibility.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float
|
||||||
|
p-value in [0, 1]. Lower is better.
|
||||||
|
"""
|
||||||
|
if len(trade_pnl) < 2:
|
||||||
|
return 1.0
|
||||||
|
trades = trade_pnl.values.copy()
|
||||||
|
real_total = float(trades.sum())
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
beat = 0
|
||||||
|
for _ in range(n_permutations):
|
||||||
|
perm = rng.permutation(trades)
|
||||||
|
if perm.sum() >= real_total:
|
||||||
|
beat += 1
|
||||||
|
return beat / n_permutations
|
||||||
|
|
||||||
|
|
||||||
|
def walk_forward_rolling(
|
||||||
|
close: pd.Series,
|
||||||
|
signal: pd.Series,
|
||||||
|
leverage: float,
|
||||||
|
txn_cost_bps: float = DEFAULT_TXN_COST_BPS,
|
||||||
|
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||||
|
is_years: int = WF_IS_YEARS,
|
||||||
|
oos_years: int = WF_OOS_YEARS,
|
||||||
|
step_years: int = WF_STEP_YEARS,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Rolling walk-forward validation: multiple IS/OOS windows shifted by ``step_years``.
|
||||||
|
|
||||||
|
Each window runs an independent FTMO simulation on the IS and OOS slices.
|
||||||
|
Produces aggregate OOS statistics to measure cross-time consistency.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys:
|
||||||
|
wf_n_windows, wf_oos_sharpe_mean, wf_oos_sharpe_std,
|
||||||
|
wf_oos_monthly_return_mean, wf_oos_consistency (fraction of windows
|
||||||
|
with OOS Sharpe > 0), wf_windows (list of per-window dicts)
|
||||||
|
"""
|
||||||
|
if not isinstance(close.index, pd.DatetimeIndex):
|
||||||
|
return {"wf_n_windows": 0}
|
||||||
|
|
||||||
|
start_year = close.index[0].year
|
||||||
|
end_year = close.index[-1].year
|
||||||
|
|
||||||
|
windows = []
|
||||||
|
yr = start_year
|
||||||
|
while True:
|
||||||
|
is_start = pd.Timestamp(f"{yr}-01-01")
|
||||||
|
is_end = pd.Timestamp(f"{yr + is_years}-01-01")
|
||||||
|
oos_end = pd.Timestamp(f"{yr + is_years + oos_years}-01-01")
|
||||||
|
if oos_end.year > end_year + 1:
|
||||||
|
break
|
||||||
|
is_mask = (close.index >= is_start) & (close.index < is_end)
|
||||||
|
oos_mask = (close.index >= is_end) & (close.index < oos_end)
|
||||||
|
if is_mask.sum() < 1000 or oos_mask.sum() < 1000:
|
||||||
|
yr += step_years
|
||||||
|
continue
|
||||||
|
|
||||||
|
window: Dict[str, Any] = {
|
||||||
|
"is_start": str(is_start.date()),
|
||||||
|
"is_end": str(is_end.date()),
|
||||||
|
"oos_start": str(is_end.date()),
|
||||||
|
"oos_end": str(oos_end.date()),
|
||||||
|
}
|
||||||
|
for mask, prefix in [(is_mask, "is"), (oos_mask, "oos")]:
|
||||||
|
close_s = close.loc[mask]
|
||||||
|
signal_s = signal.loc[mask]
|
||||||
|
masked_s, _ = _apply_ftmo_mask(signal_s, close_s, leverage, txn_cost_bps)
|
||||||
|
r = backtest_signal(close=close_s, signal=masked_s,
|
||||||
|
txn_cost_bps=txn_cost_bps, bars_per_year=bars_per_year)
|
||||||
|
window[f"{prefix}_sharpe"] = r.get("sharpe", 0.0)
|
||||||
|
window[f"{prefix}_monthly_return_pct"] = r.get("monthly_return_pct", 0.0)
|
||||||
|
window[f"{prefix}_n_trades"] = r.get("n_trades", 0)
|
||||||
|
windows.append(window)
|
||||||
|
yr += step_years
|
||||||
|
|
||||||
|
if not windows:
|
||||||
|
return {"wf_n_windows": 0}
|
||||||
|
|
||||||
|
oos_sharpes = [w["oos_sharpe"] for w in windows]
|
||||||
|
oos_monthly = [w["oos_monthly_return_pct"] for w in windows]
|
||||||
|
return {
|
||||||
|
"wf_n_windows": len(windows),
|
||||||
|
"wf_oos_sharpe_mean": float(np.mean(oos_sharpes)),
|
||||||
|
"wf_oos_sharpe_std": float(np.std(oos_sharpes)),
|
||||||
|
"wf_oos_monthly_return_mean": float(np.mean(oos_monthly)),
|
||||||
|
"wf_oos_consistency": float(np.mean([s > 0 for s in oos_sharpes])),
|
||||||
|
"wf_windows": windows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def backtest_signal_ftmo(
|
def backtest_signal_ftmo(
|
||||||
close: pd.Series,
|
close: pd.Series,
|
||||||
@@ -354,6 +476,8 @@ def backtest_signal_ftmo(
|
|||||||
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
|
||||||
forward_returns: Optional[pd.Series] = None,
|
forward_returns: Optional[pd.Series] = None,
|
||||||
oos_start: Optional[str] = OOS_START_DEFAULT,
|
oos_start: Optional[str] = OOS_START_DEFAULT,
|
||||||
|
wf_rolling: bool = False,
|
||||||
|
mc_n_permutations: int = 0,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
FTMO-compliant backtest of a strategy signal on EUR/USD.
|
FTMO-compliant backtest of a strategy signal on EUR/USD.
|
||||||
@@ -385,6 +509,13 @@ def backtest_signal_ftmo(
|
|||||||
Maximum leverage (default 30 = FTMO 1:30).
|
Maximum leverage (default 30 = FTMO 1:30).
|
||||||
oos_start : str or None
|
oos_start : str or None
|
||||||
Start of out-of-sample period (ISO date). None disables OOS split.
|
Start of out-of-sample period (ISO date). None disables OOS split.
|
||||||
|
wf_rolling : bool
|
||||||
|
If True, run rolling walk-forward validation (multiple IS/OOS windows).
|
||||||
|
Results are stored under ``wf_*`` keys. Default False.
|
||||||
|
mc_n_permutations : int
|
||||||
|
Number of Monte Carlo trade permutations. 0 = disabled (default).
|
||||||
|
When > 0, computes ``mc_pvalue``: fraction of permuted sequences whose
|
||||||
|
total return >= real total return. p < 0.05 indicates a genuine edge.
|
||||||
"""
|
"""
|
||||||
stop_price = stop_pips * FTMO_PIP
|
stop_price = stop_pips * FTMO_PIP
|
||||||
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
|
leverage_by_risk = risk_pct / (stop_price / eurusd_price)
|
||||||
@@ -440,6 +571,28 @@ def backtest_signal_ftmo(
|
|||||||
result["is_n_bars"] = int(is_mask.sum())
|
result["is_n_bars"] = int(is_mask.sum())
|
||||||
result["oos_n_bars"] = int(oos_mask.sum())
|
result["oos_n_bars"] = int(oos_mask.sum())
|
||||||
|
|
||||||
|
# Rolling walk-forward validation
|
||||||
|
if wf_rolling:
|
||||||
|
wf = walk_forward_rolling(
|
||||||
|
close=close,
|
||||||
|
signal=signal,
|
||||||
|
leverage=leverage,
|
||||||
|
txn_cost_bps=txn_cost_bps,
|
||||||
|
bars_per_year=bars_per_year,
|
||||||
|
)
|
||||||
|
result.update(wf)
|
||||||
|
|
||||||
|
# Monte Carlo trade permutation test
|
||||||
|
if mc_n_permutations > 0:
|
||||||
|
position = masked_signal.shift(1).fillna(0)
|
||||||
|
bar_ret = close.pct_change().fillna(0)
|
||||||
|
txn_cost = txn_cost_bps / 10_000.0
|
||||||
|
position_change = position.diff().abs().fillna(position.abs())
|
||||||
|
strat_ret = position * bar_ret - position_change * txn_cost
|
||||||
|
trade_pnl = _compute_trade_pnl(position, strat_ret)
|
||||||
|
result["mc_pvalue"] = monte_carlo_trade_pvalue(trade_pnl, mc_n_permutations)
|
||||||
|
result["mc_n_permutations"] = mc_n_permutations
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ model_cls = AntiSymmetricConv
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
node_features = torch.load("node_features.pt")
|
node_features = torch.load("node_features.pt", weights_only=True)
|
||||||
edge_index = torch.load("edge_index.pt")
|
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||||
|
|
||||||
# Model instantiation and forward pass
|
# Model instantiation and forward pass
|
||||||
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ model_cls = DirGNNConv
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
node_features = torch.load("node_features.pt")
|
node_features = torch.load("node_features.pt", weights_only=True)
|
||||||
edge_index = torch.load("edge_index.pt")
|
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||||
|
|
||||||
# Model instantiation and forward pass
|
# Model instantiation and forward pass
|
||||||
model = DirGNNConv(MessagePassing())
|
model = DirGNNConv(MessagePassing())
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ model_cls = GPSConv
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
node_features = torch.load("node_features.pt")
|
node_features = torch.load("node_features.pt", weights_only=True)
|
||||||
edge_index = torch.load("edge_index.pt")
|
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||||
|
|
||||||
# Model instantiation and forward pass
|
# Model instantiation and forward pass
|
||||||
model = GPSConv(channels=node_features.size(-1), conv=MessagePassing())
|
model = GPSConv(channels=node_features.size(-1), conv=MessagePassing())
|
||||||
|
|||||||
@@ -170,8 +170,8 @@ class LINKX(torch.nn.Module):
|
|||||||
model_cls = LINKX
|
model_cls = LINKX
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
node_features = torch.load("node_features.pt")
|
node_features = torch.load("node_features.pt", weights_only=True)
|
||||||
edge_index = torch.load("edge_index.pt")
|
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||||
|
|
||||||
# Model instantiation and forward pass
|
# Model instantiation and forward pass
|
||||||
model = LINKX(
|
model = LINKX(
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ class PMLP(torch.nn.Module):
|
|||||||
model_cls = PMLP
|
model_cls = PMLP
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
node_features = torch.load("node_features.pt")
|
node_features = torch.load("node_features.pt", weights_only=True)
|
||||||
edge_index = torch.load("edge_index.pt")
|
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||||
|
|
||||||
# Model instantiation and forward pass
|
# Model instantiation and forward pass
|
||||||
model = PMLP(
|
model = PMLP(
|
||||||
|
|||||||
@@ -1180,8 +1180,8 @@ model_cls = ViSNet
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
node_features = torch.load("node_features.pt")
|
node_features = torch.load("node_features.pt", weights_only=True)
|
||||||
edge_index = torch.load("edge_index.pt")
|
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||||
|
|
||||||
# Model instantiation and forward pass
|
# Model instantiation and forward pass
|
||||||
model = ViSNet()
|
model = ViSNet()
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ class AntiSymmetricConv(torch.nn.Module):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
node_features = torch.load("node_features.pt")
|
node_features = torch.load("node_features.pt", weights_only=True)
|
||||||
edge_index = torch.load("edge_index.pt")
|
edge_index = torch.load("edge_index.pt", weights_only=True)
|
||||||
|
|
||||||
# Model instantiation and forward pass
|
# Model instantiation and forward pass
|
||||||
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
model = AntiSymmetricConv(in_channels=node_features.size(-1))
|
||||||
|
|||||||
+6
-6
@@ -85,12 +85,12 @@ def preprocess_script():
|
|||||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||||
"""
|
"""
|
||||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
|
||||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||||
|
|
||||||
return X_train, X_valid, y_train, y_valid, X_test, *others
|
return X_train, X_valid, y_train, y_valid, X_test, *others
|
||||||
X_train, X_valid, y_train, y_valid = prepreprocess()
|
X_train, X_valid, y_train, y_valid = prepreprocess()
|
||||||
|
|||||||
+5
-5
@@ -82,11 +82,11 @@ def preprocess_script():
|
|||||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||||
"""
|
"""
|
||||||
if os.path.exists("X_train.pkl"):
|
if os.path.exists("X_train.pkl"):
|
||||||
X_train = pd.read_pickle("X_train.pkl")
|
X_train = pd.read_pickle("X_train.pkl") # nosec B301
|
||||||
X_valid = pd.read_pickle("X_valid.pkl")
|
X_valid = pd.read_pickle("X_valid.pkl") # nosec B301
|
||||||
y_train = pd.read_pickle("y_train.pkl")
|
y_train = pd.read_pickle("y_train.pkl") # nosec B301
|
||||||
y_valid = pd.read_pickle("y_valid.pkl")
|
y_valid = pd.read_pickle("y_valid.pkl") # nosec B301
|
||||||
X_test = pd.read_pickle("X_test.pkl")
|
X_test = pd.read_pickle("X_test.pkl") # nosec B301
|
||||||
return X_train, X_valid, y_train, y_valid, X_test
|
return X_train, X_valid, y_train, y_valid, X_test
|
||||||
|
|
||||||
X_train, X_valid, y_train, y_valid, test, status_encoder, test_ids = prepreprocess()
|
X_train, X_valid, y_train, y_valid, test, status_encoder, test_ids = prepreprocess()
|
||||||
|
|||||||
+6
-6
@@ -73,12 +73,12 @@ def preprocess_script():
|
|||||||
This method applies the preprocessing steps to the training, validation, and test datasets.
|
This method applies the preprocessing steps to the training, validation, and test datasets.
|
||||||
"""
|
"""
|
||||||
if os.path.exists("/kaggle/input/X_train.pkl"):
|
if os.path.exists("/kaggle/input/X_train.pkl"):
|
||||||
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
|
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
|
||||||
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
|
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
|
||||||
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
|
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
|
||||||
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
|
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
|
||||||
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
|
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
|
||||||
others = pd.read_pickle("/kaggle/input/others.pkl")
|
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
|
||||||
y_train = pd.Series(y_train).reset_index(drop=True)
|
y_train = pd.Series(y_train).reset_index(drop=True)
|
||||||
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
y_valid = pd.Series(y_valid).reset_index(drop=True)
|
||||||
|
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ def set_baseline():
|
|||||||
return jsonify({"baseline_score": score, "status": "set"})
|
return jsonify({"baseline_score": score, "status": "set"})
|
||||||
|
|
||||||
|
|
||||||
def run_server(task: str, base_model: str, workspace: str, host: str = "0.0.0.0", port: int = 5000):
|
def run_server(task: str, base_model: str, workspace: str, host: str = "127.0.0.1", port: int = 5000):
|
||||||
"""启动服务器"""
|
"""启动服务器"""
|
||||||
init_server(task, base_model, workspace)
|
init_server(task, base_model, workspace)
|
||||||
logger.info(f"Grading Server | task={task} | {host}:{port}")
|
logger.info(f"Grading Server | task={task} | {host}:{port}")
|
||||||
@@ -435,7 +435,7 @@ class LocalServerContext(GradingServerContext):
|
|||||||
logger.info(f"[Local Mode] Starting evaluation server on port {self.port}...")
|
logger.info(f"[Local Mode] Starting evaluation server on port {self.port}...")
|
||||||
self.server = init_server(self.task, self.base_model, self.workspace)
|
self.server = init_server(self.task, self.base_model, self.workspace)
|
||||||
|
|
||||||
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True)
|
self._http_server = make_server("0.0.0.0", self.port, app, threaded=True) # nosec B104 — intentional: Docker sandbox requires all-interface binding
|
||||||
self._thread = threading.Thread(target=self._http_server.serve_forever, daemon=True)
|
self._thread = threading.Thread(target=self._http_server.serve_forever, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
@@ -488,7 +488,7 @@ if __name__ == "__main__":
|
|||||||
parser.add_argument("--base-model", type=str, default="")
|
parser.add_argument("--base-model", type=str, default="")
|
||||||
parser.add_argument("--workspace", type=str, default=".")
|
parser.add_argument("--workspace", type=str, default=".")
|
||||||
parser.add_argument("--port", type=int, default=5000)
|
parser.add_argument("--port", type=int, default=5000)
|
||||||
parser.add_argument("--host", type=str, default="0.0.0.0")
|
parser.add_argument("--host", type=str, default="127.0.0.1")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
run_server(args.task, args.base_model, args.workspace, args.host, args.port)
|
run_server(args.task, args.base_model, args.workspace, args.host, args.port)
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ tables
|
|||||||
tree-sitter-python
|
tree-sitter-python
|
||||||
tree-sitter
|
tree-sitter
|
||||||
|
|
||||||
python-dotenv
|
python-dotenv>=1.2.2 # CVE: symlink following allows arbitrary file overwrite
|
||||||
|
|
||||||
# infrastructure related.
|
# infrastructure related.
|
||||||
docker
|
docker
|
||||||
|
|||||||
@@ -371,6 +371,8 @@ signal.fillna(0).to_pickle('signal.pkl')
|
|||||||
txn_cost_bps=TXN_COST_BPS,
|
txn_cost_bps=TXN_COST_BPS,
|
||||||
forward_returns=fwd_returns,
|
forward_returns=fwd_returns,
|
||||||
oos_start=OOS_START_DEFAULT,
|
oos_start=OOS_START_DEFAULT,
|
||||||
|
wf_rolling=True,
|
||||||
|
mc_n_permutations=200,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -567,9 +569,18 @@ def main(target_count=10):
|
|||||||
progress.update(task, advance=1)
|
progress.update(task, advance=1)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check acceptance criteria — OOS must be profitable (primary filter)
|
# Monte Carlo p-value (edge significance)
|
||||||
|
mc_pvalue = bt_result.get('mc_pvalue')
|
||||||
|
|
||||||
|
# Rolling walk-forward metrics
|
||||||
|
wf_consistency = bt_result.get('wf_oos_consistency')
|
||||||
|
wf_sharpe_mean = bt_result.get('wf_oos_sharpe_mean')
|
||||||
|
|
||||||
|
# Check acceptance criteria — OOS must be profitable + statistically significant
|
||||||
|
mc_ok = mc_pvalue is None or mc_pvalue < 0.20 # lenient: top 20% non-random
|
||||||
|
wf_ok = wf_consistency is None or wf_consistency >= 0.5 # ≥50% of WF windows profitable
|
||||||
if (abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
|
if (abs(ic) > MIN_IC and sharpe > MIN_SHARPE and trades > MIN_TRADES and dd > MAX_DRAWDOWN
|
||||||
and oos_sharpe > 0.0 and oos_monthly > 0.0):
|
and oos_sharpe > 0.0 and oos_monthly > 0.0 and mc_ok and wf_ok):
|
||||||
# ACCEPT
|
# ACCEPT
|
||||||
strategy['real_backtest'] = bt_result
|
strategy['real_backtest'] = bt_result
|
||||||
strategy['metrics'] = bt_result
|
strategy['metrics'] = bt_result
|
||||||
@@ -583,7 +594,7 @@ def main(target_count=10):
|
|||||||
'ohlcv_only': OHLCV_ONLY,
|
'ohlcv_only': OHLCV_ONLY,
|
||||||
'engine': 'ftmo_v2',
|
'engine': 'ftmo_v2',
|
||||||
'txn_cost_bps': TXN_COST_BPS,
|
'txn_cost_bps': TXN_COST_BPS,
|
||||||
# Walk-forward OOS metrics
|
# Walk-forward OOS split
|
||||||
'oos_sharpe': bt_result.get('oos_sharpe'),
|
'oos_sharpe': bt_result.get('oos_sharpe'),
|
||||||
'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'),
|
'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'),
|
||||||
'oos_max_drawdown': bt_result.get('oos_max_drawdown'),
|
'oos_max_drawdown': bt_result.get('oos_max_drawdown'),
|
||||||
@@ -592,6 +603,15 @@ def main(target_count=10):
|
|||||||
'is_sharpe': bt_result.get('is_sharpe'),
|
'is_sharpe': bt_result.get('is_sharpe'),
|
||||||
'is_monthly_return_pct': bt_result.get('is_monthly_return_pct'),
|
'is_monthly_return_pct': bt_result.get('is_monthly_return_pct'),
|
||||||
'oos_start': bt_result.get('oos_start'),
|
'oos_start': bt_result.get('oos_start'),
|
||||||
|
# Rolling walk-forward
|
||||||
|
'wf_n_windows': bt_result.get('wf_n_windows'),
|
||||||
|
'wf_oos_sharpe_mean': wf_sharpe_mean,
|
||||||
|
'wf_oos_sharpe_std': bt_result.get('wf_oos_sharpe_std'),
|
||||||
|
'wf_oos_monthly_return_mean': bt_result.get('wf_oos_monthly_return_mean'),
|
||||||
|
'wf_oos_consistency': wf_consistency,
|
||||||
|
# Monte Carlo significance
|
||||||
|
'mc_pvalue': mc_pvalue,
|
||||||
|
'mc_n_permutations': bt_result.get('mc_n_permutations'),
|
||||||
}
|
}
|
||||||
|
|
||||||
fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
|
fname = f"{int(time.time())}_{strategy['strategy_name']}.json"
|
||||||
@@ -614,12 +634,16 @@ def main(target_count=10):
|
|||||||
f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}")
|
f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}")
|
||||||
else:
|
else:
|
||||||
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
|
oos_info = f"OOS_Sharpe={oos_sharpe:+.2f} OOS_Mon={oos_monthly:+.2f}%" if oos_sharpe is not None else ""
|
||||||
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%} {oos_info}")
|
mc_info = f" MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else ""
|
||||||
|
wf_info = f" WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else ""
|
||||||
|
_log.info(f"REJECTED IC={ic:.4f} Sharpe={sharpe:.2f} Trades={trades} DD={dd:.1%} {oos_info}{mc_info}{wf_info}")
|
||||||
feedback_history.append(
|
feedback_history.append(
|
||||||
f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, "
|
f"Failed: IC={ic:.4f}, Sharpe={sharpe:.2f}, Trades={trades}, DD={dd:.1%}, "
|
||||||
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%. "
|
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
|
||||||
f"Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
|
+ (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
|
||||||
f"OOS_Sharpe>0 AND OOS_Monthly>0 — strategy must generalise to unseen data (2024+)."
|
+ (f", WF_consistency={wf_consistency:.0%}" if wf_consistency is not None else "")
|
||||||
|
+ f". Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
|
||||||
|
f"OOS_Sharpe>0, OOS_Monthly>0, MC_p<0.20, WF_consistency≥50%."
|
||||||
)
|
)
|
||||||
|
|
||||||
progress.update(task, advance=1)
|
progress.update(task, advance=1)
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ def rebacktest_one(
|
|||||||
close=close_a,
|
close=close_a,
|
||||||
signal=signal,
|
signal=signal,
|
||||||
txn_cost_bps=txn_cost_bps,
|
txn_cost_bps=txn_cost_bps,
|
||||||
|
wf_rolling=True,
|
||||||
|
mc_n_permutations=200,
|
||||||
)
|
)
|
||||||
result["status_detail"] = result.pop("status")
|
result["status_detail"] = result.pop("status")
|
||||||
result["status"] = "ok"
|
result["status"] = "ok"
|
||||||
@@ -264,6 +266,15 @@ def main() -> None:
|
|||||||
"oos_win_rate": bt.get("oos_win_rate"),
|
"oos_win_rate": bt.get("oos_win_rate"),
|
||||||
"oos_n_trades": bt.get("oos_n_trades"),
|
"oos_n_trades": bt.get("oos_n_trades"),
|
||||||
"oos_start": bt.get("oos_start"),
|
"oos_start": bt.get("oos_start"),
|
||||||
|
# Rolling walk-forward
|
||||||
|
"wf_n_windows": bt.get("wf_n_windows"),
|
||||||
|
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
|
||||||
|
"wf_oos_sharpe_std": bt.get("wf_oos_sharpe_std"),
|
||||||
|
"wf_oos_monthly_return_mean": bt.get("wf_oos_monthly_return_mean"),
|
||||||
|
"wf_oos_consistency": bt.get("wf_oos_consistency"),
|
||||||
|
# Monte Carlo significance
|
||||||
|
"mc_pvalue": bt.get("mc_pvalue"),
|
||||||
|
"mc_n_permutations": bt.get("mc_n_permutations"),
|
||||||
}
|
}
|
||||||
data["sharpe_ratio"] = bt.get("sharpe")
|
data["sharpe_ratio"] = bt.get("sharpe")
|
||||||
data["max_drawdown"] = bt.get("max_drawdown")
|
data["max_drawdown"] = bt.get("max_drawdown")
|
||||||
@@ -299,6 +310,12 @@ def main() -> None:
|
|||||||
"oos_monthly_pct": bt.get("oos_monthly_return_pct"),
|
"oos_monthly_pct": bt.get("oos_monthly_return_pct"),
|
||||||
"oos_dd": bt.get("oos_max_drawdown"),
|
"oos_dd": bt.get("oos_max_drawdown"),
|
||||||
"oos_trades": bt.get("oos_n_trades"),
|
"oos_trades": bt.get("oos_n_trades"),
|
||||||
|
# Rolling walk-forward
|
||||||
|
"wf_n_windows": bt.get("wf_n_windows"),
|
||||||
|
"wf_oos_sharpe_mean": bt.get("wf_oos_sharpe_mean"),
|
||||||
|
"wf_oos_consistency": bt.get("wf_oos_consistency"),
|
||||||
|
# Monte Carlo
|
||||||
|
"mc_pvalue": bt.get("mc_pvalue"),
|
||||||
}
|
}
|
||||||
if "annualized_return" in bt:
|
if "annualized_return" in bt:
|
||||||
row["new_annual_return_cagr"] = bt["annualized_return"]
|
row["new_annual_return_cagr"] = bt["annualized_return"]
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""
|
||||||
|
Tests for backtest_signal_ftmo and walk-forward OOS validation.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- FTMO daily/total loss limits
|
||||||
|
- Risk-based leverage calculation
|
||||||
|
- OOS split returns independent IS and OOS metrics
|
||||||
|
- OOS uses fresh FTMO simulation (not contaminated by IS losses)
|
||||||
|
- Monte Carlo permutation test helper
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from rdagent.components.backtesting.vbt_backtest import (
|
||||||
|
OOS_START_DEFAULT,
|
||||||
|
backtest_signal_ftmo,
|
||||||
|
FTMO_MAX_DAILY_LOSS,
|
||||||
|
FTMO_MAX_TOTAL_LOSS,
|
||||||
|
monte_carlo_trade_pvalue,
|
||||||
|
walk_forward_rolling,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.fixture
|
||||||
|
def close_2yr() -> pd.Series:
|
||||||
|
"""~3 months of synthetic 1-min EUR/USD (enough bars for all leverage/FTMO tests)."""
|
||||||
|
np.random.seed(42)
|
||||||
|
n = 90 * 1440 # 90 days × 1440 min
|
||||||
|
idx = pd.date_range("2022-01-01", periods=n, freq="1min")
|
||||||
|
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
|
||||||
|
return pd.Series(price, index=idx)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def close_6yr() -> pd.Series:
|
||||||
|
"""Synthetic data crossing the 2024-01-01 IS/OOS boundary.
|
||||||
|
|
||||||
|
120 days starting 2023-09-01 → ends ~2024-01-01, giving ~30 days of OOS data.
|
||||||
|
Small enough to keep tests fast.
|
||||||
|
"""
|
||||||
|
np.random.seed(7)
|
||||||
|
n = 150 * 1440 # 2023-09-01 + 150d ≈ 2024-01-28 → ~28 days of OOS data
|
||||||
|
idx = pd.date_range("2023-09-01", periods=n, freq="1min")
|
||||||
|
price = 1.10 + np.cumsum(np.random.randn(n) * 0.00005)
|
||||||
|
return pd.Series(price, index=idx)
|
||||||
|
|
||||||
|
|
||||||
|
def _random_signal(index: pd.Index, seed: int = 0) -> pd.Series:
|
||||||
|
np.random.seed(seed)
|
||||||
|
return pd.Series(np.random.choice([-1.0, 0.0, 1.0], size=len(index)), index=index)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FTMO leverage tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_ftmo_result_contains_leverage_fields(close_2yr):
|
||||||
|
signal = _random_signal(close_2yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||||
|
assert "ftmo_leverage" in r
|
||||||
|
assert "ftmo_risk_pct" in r
|
||||||
|
assert "ftmo_stop_pips" in r
|
||||||
|
assert r["ftmo_leverage"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ftmo_leverage_capped_at_max(close_2yr):
|
||||||
|
signal = _random_signal(close_2yr.index)
|
||||||
|
# With very tight stop (1 pip) risk_pct=0.5% → leverage would be 55x → capped at 30
|
||||||
|
r = backtest_signal_ftmo(close_2yr, signal, stop_pips=1, max_leverage=30, oos_start=None)
|
||||||
|
assert r["ftmo_leverage"] <= 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ftmo_zero_signal_produces_no_trades(close_2yr):
|
||||||
|
signal = pd.Series(0.0, index=close_2yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||||
|
assert r["n_trades"] == 0
|
||||||
|
assert r["total_return"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# OOS split tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_oos_split_produces_is_and_oos_keys(close_6yr):
|
||||||
|
signal = _random_signal(close_6yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||||
|
|
||||||
|
assert "is_sharpe" in r
|
||||||
|
assert "oos_sharpe" in r
|
||||||
|
assert "is_monthly_return_pct" in r
|
||||||
|
assert "oos_monthly_return_pct" in r
|
||||||
|
assert "is_n_bars" in r
|
||||||
|
assert "oos_n_bars" in r
|
||||||
|
assert r["oos_start"] == "2024-01-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oos_split_bars_sum_to_total(close_6yr):
|
||||||
|
signal = _random_signal(close_6yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||||
|
assert r["is_n_bars"] + r["oos_n_bars"] == len(close_6yr)
|
||||||
|
|
||||||
|
|
||||||
|
def test_oos_none_disables_split(close_6yr):
|
||||||
|
signal = _random_signal(close_6yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_6yr, signal, oos_start=None)
|
||||||
|
assert "is_sharpe" not in r
|
||||||
|
assert "oos_sharpe" not in r
|
||||||
|
|
||||||
|
|
||||||
|
def test_oos_is_independent_of_is_losses(close_6yr):
|
||||||
|
"""OOS must use a fresh FTMO simulation — IS blowup must not zero OOS trades."""
|
||||||
|
# Force the IS period to blow up immediately with max short on rising market
|
||||||
|
rising = pd.Series(
|
||||||
|
np.linspace(1.0, 2.0, len(close_6yr)),
|
||||||
|
index=close_6yr.index,
|
||||||
|
)
|
||||||
|
always_short = pd.Series(-1.0, index=close_6yr.index)
|
||||||
|
|
||||||
|
r = backtest_signal_ftmo(rising, always_short, oos_start="2024-01-01")
|
||||||
|
|
||||||
|
# IS should be wiped out (total loss limit hit), but OOS must still trade
|
||||||
|
assert r.get("oos_n_trades", 0) is not None
|
||||||
|
assert r.get("oos_n_bars", 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_oos_default_start_matches_constant(close_6yr):
|
||||||
|
signal = _random_signal(close_6yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_6yr, signal)
|
||||||
|
assert r.get("oos_start") == OOS_START_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Monte Carlo permutation test helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _monte_carlo_pvalue(close: pd.Series, signal: pd.Series, n_permutations: int = 200, seed: int = 0) -> float:
|
||||||
|
"""
|
||||||
|
Estimate p-value: fraction of random permutations that beat the real Sharpe.
|
||||||
|
p < 0.05 → strategy has statistically significant edge.
|
||||||
|
"""
|
||||||
|
real_r = backtest_signal_ftmo(close, signal, oos_start=None)
|
||||||
|
real_sharpe = real_r.get("sharpe", 0.0) or 0.0
|
||||||
|
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
beat = 0
|
||||||
|
signal_vals = signal.values.copy()
|
||||||
|
for _ in range(n_permutations):
|
||||||
|
perm = rng.permutation(signal_vals)
|
||||||
|
perm_signal = pd.Series(perm, index=signal.index)
|
||||||
|
perm_r = backtest_signal_ftmo(close, perm_signal, oos_start=None)
|
||||||
|
if (perm_r.get("sharpe") or 0.0) >= real_sharpe:
|
||||||
|
beat += 1
|
||||||
|
return beat / n_permutations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_random_signal_has_no_edge(close_2yr):
|
||||||
|
"""A purely random signal should NOT beat most permutations."""
|
||||||
|
signal = _random_signal(close_2yr.index, seed=42)
|
||||||
|
pval = _monte_carlo_pvalue(close_2yr, signal, n_permutations=50)
|
||||||
|
# Random vs random: p-value should be near 0.5 (not significant)
|
||||||
|
assert pval > 0.10, f"Random signal unexpectedly significant: p={pval:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_perfect_signal_is_significant(close_2yr):
|
||||||
|
"""An oracle signal on hourly bars should beat random permutations significantly.
|
||||||
|
|
||||||
|
Per-minute oracle trading is unprofitable due to FTMO transaction costs, so we
|
||||||
|
use 60-bar held positions (≈1h) where each directional move is large enough to
|
||||||
|
cover the spread.
|
||||||
|
"""
|
||||||
|
bar_ret = close_2yr.pct_change().fillna(0)
|
||||||
|
# Hourly oracle: sign of 60-bar future return, broadcast to all 60 minute bars
|
||||||
|
hourly_ret = bar_ret.rolling(60).sum().shift(-60).fillna(0)
|
||||||
|
perfect = pd.Series(np.sign(hourly_ret), index=close_2yr.index)
|
||||||
|
pval = _monte_carlo_pvalue(close_2yr, perfect, n_permutations=50)
|
||||||
|
assert pval < 0.30, f"Hourly oracle signal should beat random permutations: p={pval:.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FTMO metrics in result dict
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_ftmo_result_has_equity_and_profit(close_2yr):
|
||||||
|
signal = _random_signal(close_2yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||||
|
assert "ftmo_end_equity" in r
|
||||||
|
assert "ftmo_monthly_profit" in r
|
||||||
|
assert r["ftmo_end_equity"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Monte Carlo trade permutation tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_mc_pvalue_in_result(close_2yr):
|
||||||
|
signal = _random_signal(close_2yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None, mc_n_permutations=50)
|
||||||
|
assert "mc_pvalue" in r
|
||||||
|
assert 0.0 <= r["mc_pvalue"] <= 1.0
|
||||||
|
assert r["mc_n_permutations"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_mc_pvalue_disabled_by_default(close_2yr):
|
||||||
|
signal = _random_signal(close_2yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_2yr, signal, oos_start=None)
|
||||||
|
assert "mc_pvalue" not in r
|
||||||
|
|
||||||
|
|
||||||
|
def test_mc_zero_trades_returns_one(close_2yr):
|
||||||
|
"""Zero-signal → no trades → p-value must be 1.0 (no edge)."""
|
||||||
|
trade_pnl = pd.Series([], dtype=float)
|
||||||
|
assert monte_carlo_trade_pvalue(trade_pnl, n_permutations=10) == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rolling walk-forward tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_wf_rolling_keys_in_result(close_6yr):
|
||||||
|
signal = _random_signal(close_6yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
|
||||||
|
# With only ~150 days of data, windows may be 0 — just check key presence
|
||||||
|
assert "wf_n_windows" in r
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_rolling_disabled_by_default(close_6yr):
|
||||||
|
signal = _random_signal(close_6yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01")
|
||||||
|
assert "wf_n_windows" not in r
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_consistency_range(close_6yr):
|
||||||
|
"""wf_oos_consistency must be in [0, 1] when windows exist."""
|
||||||
|
signal = _random_signal(close_6yr.index)
|
||||||
|
r = backtest_signal_ftmo(close_6yr, signal, oos_start="2024-01-01", wf_rolling=True)
|
||||||
|
c = r.get("wf_oos_consistency")
|
||||||
|
if c is not None:
|
||||||
|
assert 0.0 <= c <= 1.0
|
||||||
Reference in New Issue
Block a user