Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ curl http://localhost:8000/leaderboard

## Sandbox

Submitted strategies (`/strategies/submit`) run in a restricted environment by default. Direct `strategy_path` runs remain available for trusted local development.

Submitted strategies run in a restricted environment:

- **Timeout**: 60 seconds maximum execution time
Expand Down
7 changes: 7 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,12 @@ def create_run(req: RunCreate, db: Session = Depends(get_db)):
else:
version = get_or_create_strategy_version(db, req.strategy_path)

# Strategies submitted via /strategies/submit are persisted under STRATEGIES_DIR
# and should run sandboxed by default. Explicit strategy_path runs are treated as trusted local code.
strategies_root = os.path.abspath(STRATEGIES_DIR)
version_path = os.path.abspath(version.strategy_path)
is_submitted_strategy = version_path.startswith(strategies_root + os.sep) or version_path == strategies_root

run_id = str(uuid.uuid4())
run = Run(
id=run_id,
Expand All @@ -242,6 +248,7 @@ def create_run(req: RunCreate, db: Session = Depends(get_db)):
dataset_version=dataset_version,
params=req.params,
run_id=run_id,
trusted_strategy=not is_submitted_strategy,
)
except Exception as exc:
run.status = "failed"
Expand Down
4 changes: 4 additions & 0 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ See `examples/strategy_template.py` for full documentation.

### Sandbox Security

`POST /runs` keeps both paths:
- `strategy_version_id` created via `POST /strategies/submit` → sandboxed execution (untrusted)
- `strategy_path` direct local file execution → trusted mode (for local dev)

| Restriction | Method |
|-------------|--------|
| Timeout | `multiprocessing.Process` + `join(timeout)` |
Expand Down
50 changes: 50 additions & 0 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from pathlib import Path

from worker.runner import run_backtest


def _write_dataset(tmp_path: Path) -> Path:
dataset_dir = tmp_path / "dataset"
dataset_dir.mkdir(parents=True, exist_ok=True)
(dataset_dir / "prices.csv").write_text("ts,price\n1,100\n2,101\n3,102\n", encoding="utf-8")
return dataset_dir


def test_run_backtest_uses_sandbox_for_untrusted_strategy(tmp_path: Path):
dataset_dir = _write_dataset(tmp_path)
strategy_path = tmp_path / "untrusted.py"
strategy_path.write_text(
"def simulate(prices, params):\n"
" assert open is None\n"
" return [1.0 for _ in prices]\n",
encoding="utf-8",
)

result = run_backtest(
strategy_path=str(strategy_path),
dataset_dir=str(dataset_dir),
dataset_version="v1",
params={},
trusted_strategy=False,
)
assert result.metrics["total_return"] == 0.0


def test_run_backtest_trusted_strategy_skips_sandbox(tmp_path: Path):
dataset_dir = _write_dataset(tmp_path)
strategy_path = tmp_path / "trusted.py"
strategy_path.write_text(
"def simulate(prices, params):\n"
" assert open is not None\n"
" return [1.0 for _ in prices]\n",
encoding="utf-8",
)

result = run_backtest(
strategy_path=str(strategy_path),
dataset_dir=str(dataset_dir),
dataset_version="v1",
params={},
trusted_strategy=True,
)
assert result.metrics["score"] == 0.0
15 changes: 8 additions & 7 deletions worker/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from shared.hashing import sha256_bytes, sha256_json
from shared.types import RunResult
from worker.sandbox import run_strategy


def hash_file(path: str) -> str:
Expand Down Expand Up @@ -47,6 +48,7 @@ def run_backtest(
dataset_version: str,
params: Dict[str, Any],
run_id: str | None = None,
trusted_strategy: bool = True,
) -> RunResult:
run_id = run_id or str(uuid.uuid4())
started = time.time()
Expand All @@ -55,17 +57,16 @@ def run_backtest(
code_hash = hash_file(strategy_path)
config_hash = sha256_json({"params": params, "dataset_version": dataset_version})

# Load strategy
strategy_globals: Dict[str, Any] = {}
with open(strategy_path, "r", encoding="utf-8") as f:
code = f.read()
exec(compile(code, strategy_path, "exec"), strategy_globals)

if "simulate" not in strategy_globals:
raise ValueError("Strategy must define simulate(prices, params) -> equity_curve")

prices = load_price_series(dataset_dir)
equity = strategy_globals["simulate"](prices=prices, params=params)
equity = run_strategy(
strategy_code=code,
prices=prices.tolist(),
params=params,
trusted=trusted_strategy,
)
equity = pd.Series(equity, dtype=float)

# Metrics (very MVP)
Expand Down