Compare commits

..

8 Commits

Author SHA1 Message Date
github-actions[bot] d17d424ee9 chore(master): release 1.3.0 (#22)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-21 22:26:05 +02:00
TPTBusiness 5d8e53d208 fix(security): resolve all 30 Bandit security alerts (B301, B614, B104)
- B301 (pickle): add nosec B301 to pd.read_pickle calls in Kaggle templates
  — files are trusted Kaggle-environment inputs, not user-supplied
- B614 (torch.load): add weights_only=True to all torch.load calls in
  model benchmark GT code and gt_code.py
- B104 (binding 0.0.0.0): change run_server and CLI default to 127.0.0.1;
  add nosec comment where all-interface binding is required for Docker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 22:24:57 +02:00
dependabot[bot] 7880a9315a chore(deps): Bump actions/setup-python from 5 to 6 (#23)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 18:52:51 +02:00
dependabot[bot] 17bba1a920 chore(deps): Bump codacy/codacy-analysis-cli-action from 1.1.0 to 4.4.7 (#24)
Bumps [codacy/codacy-analysis-cli-action](https://github.com/codacy/codacy-analysis-cli-action) from 1.1.0 to 4.4.7.
- [Release notes](https://github.com/codacy/codacy-analysis-cli-action/releases)
- [Commits](https://github.com/codacy/codacy-analysis-cli-action/compare/d840f886c4bd4edc059706d09c6a1586111c540b...562ee3e92b8e92df8b67e0a5ff8aa8e261919c08)

---
updated-dependencies:
- dependency-name: codacy/codacy-analysis-cli-action
  dependency-version: 4.4.7
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 18:52:48 +02:00
dependabot[bot] 360df4083a chore(deps): Bump actions/checkout from 4 to 6 (#25)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 18:52:45 +02:00
dependabot[bot] e2c2fefe9a chore(deps): Bump actions/upload-pages-artifact from 3 to 5 (#26)
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 18:52:36 +02:00
TPTBusiness 32f7d66e07 feat(backtest): add rolling walk-forward validation and Monte Carlo trade permutation test
- monte_carlo_trade_pvalue(): shuffles trade P&L N times, returns fraction of
  permuted sequences that beat real total return (p<0.05 = genuine edge)
- walk_forward_rolling(): multiple IS/OOS windows (IS=3yr, OOS=1yr, step=1yr),
  computes wf_oos_sharpe_mean, wf_oos_consistency (% profitable windows)
- backtest_signal_ftmo(): new wf_rolling and mc_n_permutations params
- Strategy generator: enables both (200 MC permutations), adds mc_ok and wf_ok
  to acceptance filter (mc_p<0.20, wf_consistency>=50%)
- Rebacktest script: enables both, stores all wf_*/mc_* fields in write-back
- 6 new tests covering MC pvalue, disabled-by-default, zero-trades edge case,
  rolling WF key presence and consistency range

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:59:00 +02:00
TPTBusiness 4d6ef04411 test(backtest): add FTMO and OOS walk-forward validation tests
Covers backtest_signal_ftmo leverage caps, zero-signal, IS/OOS split keys,
bar counts, OOS independence from IS losses, and Monte Carlo permutation
tests (marked slow, excluded from default pytest run).

Also excludes slow-marked tests from default addopts in pyproject.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:26:32 +02:00
26 changed files with 513 additions and 60 deletions
+3 -3
View File
@@ -14,7 +14,7 @@ jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Run Bandit (Security Scan)
uses: PyCQA/bandit-action@v1
@@ -25,9 +25,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.10"
cache: "pip"
+2 -2
View File
@@ -36,11 +36,11 @@ jobs:
steps:
# Checkout the repository to the GitHub Actions runner
- 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
- name: Run Codacy Analysis CLI
uses: codacy/codacy-analysis-cli-action@d840f886c4bd4edc059706d09c6a1586111c540b
uses: codacy/codacy-analysis-cli-action@562ee3e92b8e92df8b67e0a5ff8aa8e261919c08
env:
JAVA_TOOL_OPTIONS: "-Dfile.encoding=UTF-8"
with:
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
name: Validate Commit Messages
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
+3 -3
View File
@@ -25,10 +25,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
@@ -64,7 +64,7 @@ jobs:
- name: Upload docs artifact
if: github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@v5
with:
path: docs/_build/html
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
+4 -4
View File
@@ -19,9 +19,9 @@ jobs:
python-version: ["3.10", "3.11"]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
cache: "pip"
@@ -49,9 +49,9 @@ jobs:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
- uses: actions/setup-python@v6
with:
python-version: "3.10"
cache: "pip"
+2 -2
View File
@@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: "3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.2.2"
".": "1.3.0"
}
+12
View File
@@ -1,5 +1,17 @@
# 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 -1
View File
@@ -68,7 +68,7 @@ ignore_missing_imports = true
module = "llama"
[tool.pytest.ini_options]
addopts = "-l -s --durations=0"
addopts = "-l -s --durations=0 -m 'not slow'"
log_cli = true
log_cli_level = "info"
log_date_format = "%Y-%m-%d %H:%M:%S"
@@ -11,16 +11,23 @@ from .vbt_backtest import (
FTMO_MAX_LEVERAGE,
FTMO_RISK_PER_TRADE,
OOS_START_DEFAULT,
WF_IS_YEARS,
WF_OOS_YEARS,
WF_STEP_YEARS,
backtest_from_forward_returns,
backtest_signal,
backtest_signal_ftmo,
monte_carlo_trade_pvalue,
walk_forward_rolling,
)
__all__ = [
'BacktestMetrics', 'FactorBacktester', 'ResultsDatabase',
'CorrelationAnalyzer', 'PortfolioOptimizer', 'AdvancedRiskManager',
'backtest_signal', 'backtest_signal_ftmo', 'backtest_from_forward_returns',
'monte_carlo_trade_pvalue', 'walk_forward_rolling',
'DEFAULT_BARS_PER_YEAR', 'DEFAULT_TXN_COST_BPS',
'FTMO_INITIAL_CAPITAL', 'FTMO_MAX_DAILY_LOSS', 'FTMO_MAX_TOTAL_LOSS',
'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"
# 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(
close: pd.Series,
@@ -354,6 +476,8 @@ def backtest_signal_ftmo(
bars_per_year: int = DEFAULT_BARS_PER_YEAR,
forward_returns: Optional[pd.Series] = None,
oos_start: Optional[str] = OOS_START_DEFAULT,
wf_rolling: bool = False,
mc_n_permutations: int = 0,
) -> Dict[str, Any]:
"""
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).
oos_start : str or None
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
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["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
@@ -123,8 +123,8 @@ model_cls = AntiSymmetricConv
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = AntiSymmetricConv(in_channels=node_features.size(-1))
@@ -78,8 +78,8 @@ model_cls = DirGNNConv
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = DirGNNConv(MessagePassing())
@@ -187,8 +187,8 @@ model_cls = GPSConv
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = GPSConv(channels=node_features.size(-1), conv=MessagePassing())
@@ -170,8 +170,8 @@ class LINKX(torch.nn.Module):
model_cls = LINKX
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = LINKX(
@@ -102,8 +102,8 @@ class PMLP(torch.nn.Module):
model_cls = PMLP
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = PMLP(
@@ -1180,8 +1180,8 @@ model_cls = ViSNet
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = ViSNet()
@@ -125,8 +125,8 @@ class AntiSymmetricConv(torch.nn.Module):
if __name__ == "__main__":
node_features = torch.load("node_features.pt")
edge_index = torch.load("edge_index.pt")
node_features = torch.load("node_features.pt", weights_only=True)
edge_index = torch.load("edge_index.pt", weights_only=True)
# Model instantiation and forward pass
model = AntiSymmetricConv(in_channels=node_features.size(-1))
@@ -85,12 +85,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
others = pd.read_pickle("/kaggle/input/others.pkl")
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
return X_train, X_valid, y_train, y_valid, X_test, *others
X_train, X_valid, y_train, y_valid = prepreprocess()
@@ -82,11 +82,11 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("X_train.pkl"):
X_train = pd.read_pickle("X_train.pkl")
X_valid = pd.read_pickle("X_valid.pkl")
y_train = pd.read_pickle("y_train.pkl")
y_valid = pd.read_pickle("y_valid.pkl")
X_test = pd.read_pickle("X_test.pkl")
X_train = pd.read_pickle("X_train.pkl") # nosec B301
X_valid = pd.read_pickle("X_valid.pkl") # nosec B301
y_train = pd.read_pickle("y_train.pkl") # nosec B301
y_valid = pd.read_pickle("y_valid.pkl") # nosec B301
X_test = pd.read_pickle("X_test.pkl") # nosec B301
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()
@@ -73,12 +73,12 @@ def preprocess_script():
This method applies the preprocessing steps to the training, validation, and test datasets.
"""
if os.path.exists("/kaggle/input/X_train.pkl"):
X_train = pd.read_pickle("/kaggle/input/X_train.pkl")
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl")
y_train = pd.read_pickle("/kaggle/input/y_train.pkl")
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl")
X_test = pd.read_pickle("/kaggle/input/X_test.pkl")
others = pd.read_pickle("/kaggle/input/others.pkl")
X_train = pd.read_pickle("/kaggle/input/X_train.pkl") # nosec B301
X_valid = pd.read_pickle("/kaggle/input/X_valid.pkl") # nosec B301
y_train = pd.read_pickle("/kaggle/input/y_train.pkl") # nosec B301
y_valid = pd.read_pickle("/kaggle/input/y_valid.pkl") # nosec B301
X_test = pd.read_pickle("/kaggle/input/X_test.pkl") # nosec B301
others = pd.read_pickle("/kaggle/input/others.pkl") # nosec B301
y_train = pd.Series(y_train).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"})
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)
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}...")
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.start()
@@ -488,7 +488,7 @@ if __name__ == "__main__":
parser.add_argument("--base-model", type=str, default="")
parser.add_argument("--workspace", type=str, default=".")
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()
run_server(args.task, args.base_model, args.workspace, args.host, args.port)
+31 -7
View File
@@ -371,6 +371,8 @@ signal.fillna(0).to_pickle('signal.pkl')
txn_cost_bps=TXN_COST_BPS,
forward_returns=fwd_returns,
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)
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
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
strategy['real_backtest'] = bt_result
strategy['metrics'] = bt_result
@@ -583,7 +594,7 @@ def main(target_count=10):
'ohlcv_only': OHLCV_ONLY,
'engine': 'ftmo_v2',
'txn_cost_bps': TXN_COST_BPS,
# Walk-forward OOS metrics
# Walk-forward OOS split
'oos_sharpe': bt_result.get('oos_sharpe'),
'oos_monthly_return_pct': bt_result.get('oos_monthly_return_pct'),
'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_monthly_return_pct': bt_result.get('is_monthly_return_pct'),
'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"
@@ -614,12 +634,16 @@ def main(target_count=10):
f"IC={ic:.4f}, Sharpe={sharpe:.3f}, Trades={trades}, DD={dd:.1%}")
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(
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"Need |IC|>{MIN_IC}, Sharpe>{MIN_SHARPE}, Trades>{MIN_TRADES}, "
f"OOS_Sharpe>0 AND OOS_Monthly>0 — strategy must generalise to unseen data (2024+)."
f"OOS_Sharpe={oos_sharpe:+.2f}, OOS_Monthly={oos_monthly:+.2f}%"
+ (f", MC_p={mc_pvalue:.2f}" if mc_pvalue is not None else "")
+ (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)
+17
View File
@@ -188,6 +188,8 @@ def rebacktest_one(
close=close_a,
signal=signal,
txn_cost_bps=txn_cost_bps,
wf_rolling=True,
mc_n_permutations=200,
)
result["status_detail"] = result.pop("status")
result["status"] = "ok"
@@ -264,6 +266,15 @@ def main() -> None:
"oos_win_rate": bt.get("oos_win_rate"),
"oos_n_trades": bt.get("oos_n_trades"),
"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["max_drawdown"] = bt.get("max_drawdown")
@@ -299,6 +310,12 @@ def main() -> None:
"oos_monthly_pct": bt.get("oos_monthly_return_pct"),
"oos_dd": bt.get("oos_max_drawdown"),
"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:
row["new_annual_return_cagr"] = bt["annualized_return"]
+240
View File
@@ -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