Skip to content
Merged
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
11 changes: 5 additions & 6 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
paths:
- 'investing_algorithm_framework/**'
- 'tests/**'
- '.github/workflows/test.yml'

jobs:
linting:
Expand All @@ -35,11 +36,11 @@ jobs:
flake8 ./investing_algorithm_framework
test:
needs: linting
timeout-minutes: 25
timeout-minutes: 40
strategy:
fail-fast: true
matrix:
os: [ "ubuntu-latest", "macos-latest" ]
os: [ "ubuntu-latest", "macos-latest", "windows-latest" ]
python-version: [ "3.10", "3.11" ]
defaults:
run:
Expand Down Expand Up @@ -101,14 +102,12 @@ jobs:
#----------------------------------------------
- name: Run tests
run: |
source $VENV
coverage run -m unittest discover -s tests
poetry run coverage run -m unittest discover -s tests
- name: Generate coverage report
if: always()
continue-on-error: true
run: |
source $VENV
coverage xml
poetry run coverage xml
#----------------------------------------------
# upload coverage stats
#----------------------------------------------
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
</h1>

<p align="center">
<i align="center">Create trading strategies. Compare them side by side. Pick the best one. 🚀</i>
<i align="center">Create trading strategies. Compare them side by side. Pick the best one and Deploy 🚀</i>
</p>

<h4 align="center">
Expand Down Expand Up @@ -61,14 +61,18 @@

Most quant frameworks stop at "here's your backtest result." You get a number, maybe a chart, and then you're on your own figuring out which strategy is actually better.

This framework is built around the full loop: **create strategies → backtest them → compare them in a single report → deploy the winner.** It generates a self-contained HTML dashboard that lets you rank, filter, and visually compare every strategy you've tested — all in one view, no notebooks required.
This framework is built around the full loop: **create strategies → vector backtest for signals analysis → compare them in a single report → event backtest the most promising strategies → deploy the winner.** It generates a self-contained HTML dashboard that lets you rank, filter, and visually compare every strategy you've tested — all in one view, no notebooks required.

<details open>
<summary>
Features
</summary> <br>

- 📊 **30+ Metrics** — CAGR, Sharpe, Sortino, Calmar, VaR, CVaR, Max DD, Recovery & more
- ⚡ **Vector Backtesting for Signal Analysis** — Quickly test your strategy logic on historical data to see how signals would have behaved before committing to full event-driven backtests
- 🏃 **Event-Driven Backtesting** — Once promising strategies are identified via vector backtests, run full event-driven backtests to simulate realistic execution and portfolio management
- 🔀 **Permutation Testing / Monte Carlo Simulations** — Assess the statistical robustness of your strategies by running them across randomized market scenarios to see how often your results could occur by chance
- 🚀 **Deployment** — Once the best strategy is identified through backtesting and comparison, deploy it to production locally or in the cloud (AWS Lambda / Azure Functions) to start live trading
- ⚔️ **Multi-Strategy Comparison** — Rank, filter & compare strategies in a single interactive report
- 🪟 **Multi-Window Robustness** — Test across different time periods with window coverage analysis
- 📈 **Equity & Drawdown Charts** — Overlay equity curves, rolling Sharpe, drawdown & return distributions
Expand Down
23 changes: 23 additions & 0 deletions investing_algorithm_framework/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,29 @@ def initialize_storage(self, remove_database_if_exists: bool = False):
logger.info(
f"Removing existing database at {database_path}"
)

# Dispose the existing engine to release file locks
# (required on Windows where locks are mandatory)
from investing_algorithm_framework.infrastructure.database \
import Session
from sqlalchemy.orm import close_all_sessions
close_all_sessions()
bind = Session.kw.get("bind")

if bind is not None:

try:
conn = bind.connect()
conn.invalidate()
conn.close()
except Exception:
pass

bind.dispose()

import gc
gc.collect()

os.remove(database_path)

# Create the sqlalchemy database uri
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def validate_and_create_checkpoints(
verbose_file_handle = None

if verbose_output_file is not None:
verbose_file_handle = open(verbose_output_file, 'w')
verbose_file_handle = open(verbose_output_file, 'w', encoding='utf-8')

def echo(msg):
verbose_file_handle.write(msg + "\n")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from .sql_alchemy import Session, setup_sqlalchemy, SQLBaseModel, \
create_all_tables, clear_db, SqliteDecimal
create_all_tables, clear_db, teardown_sqlalchemy, SqliteDecimal

__all__ = [
"Session",
"setup_sqlalchemy",
"SQLBaseModel",
"create_all_tables",
"clear_db",
"teardown_sqlalchemy",
"SqliteDecimal"
]
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

from sqlalchemy import create_engine, StaticPool, String
from sqlalchemy import inspect
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from sqlalchemy.orm import DeclarativeBase, sessionmaker, close_all_sessions
from sqlalchemy import TypeDecorator

from investing_algorithm_framework.domain import SQLALCHEMY_DATABASE_URI, \
Expand Down Expand Up @@ -83,6 +83,34 @@ def create_all_tables():
SQLBaseModel.metadata.create_all(bind=Session().bind)


def teardown_sqlalchemy():
"""
Dispose the engine and close all sessions to release file locks.
This is essential on Windows where file locks are mandatory and
prevent deletion of SQLite database files while connections are open.
"""
close_all_sessions()
bind = Session.kw.get("bind")

if bind is not None:

# StaticPool._close_connection() is a no-op, so
# engine.dispose() alone won't close the underlying DBAPI
# connection. Use invalidate() which bypasses the pool's
# _close_connection and calls dialect.do_close() directly,
# ensuring the sqlite3 file lock is released on Windows.
try:
conn = bind.connect()
conn.invalidate()
conn.close()
except Exception:
pass

bind.dispose()

Session.configure(bind=None)


from sqlalchemy import event
from sqlalchemy.orm import mapper
from datetime import timezone
Expand All @@ -98,6 +126,7 @@ def clear_db(db_uri):
Returns:
None
"""
engine = None
# Drop all tables before deleting file
try:
engine = create_engine(db_uri)
Expand All @@ -107,12 +136,9 @@ def clear_db(db_uri):
SQLBaseModel.metadata.drop_all(bind=engine)
except Exception as e:
logger.error(f"Error dropping tables: {e}")

# # Clear mappers (if using classical mappings)
# try:
# clear_mappers()
# except Exception:
# pass # ignore if not needed
finally:
if engine is not None:
engine.dispose()


@event.listens_for(mapper, "load")
Expand Down
3 changes: 3 additions & 0 deletions tests/app/algorithm/test_run_strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from investing_algorithm_framework import create_app, TradingStrategy, \
TimeUnit, PortfolioConfiguration, RESOURCE_DIRECTORY, Algorithm, \
MarketCredential
from investing_algorithm_framework.infrastructure.database import \
teardown_sqlalchemy
from tests.resources import random_string, OrderExecutorTest, \
PortfolioProviderTest

Expand Down Expand Up @@ -73,6 +75,7 @@ def setUp(self) -> None:

def tearDown(self) -> None:
super().tearDown()
teardown_sqlalchemy()
database_dir = os.path.join(self.resource_dir, "databases")
if os.path.exists(database_dir):
shutil.rmtree(database_dir, ignore_errors=True)
Expand Down
3 changes: 3 additions & 0 deletions tests/app/algorithm/test_trade_price_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
TimeUnit, PortfolioConfiguration, RESOURCE_DIRECTORY, \
MarketCredential, DataSource, INDEX_DATETIME, DataType, \
CSVOHLCVDataProvider, BacktestDateRange
from investing_algorithm_framework.infrastructure.database import \
teardown_sqlalchemy
from tests.resources import random_string, \
PortfolioProviderTest, OrderExecutorTest

Expand Down Expand Up @@ -55,6 +57,7 @@ def setUp(self) -> None:

def tearDown(self) -> None:
super().tearDown()
teardown_sqlalchemy()
database_dir = os.path.join(self.resource_dir, "databases")
if os.path.exists(database_dir):
shutil.rmtree(database_dir, ignore_errors=True)
Expand Down
7 changes: 5 additions & 2 deletions tests/app/backtesting/test_run_backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from investing_algorithm_framework import create_app, RESOURCE_DIRECTORY, \
TradingStrategy, PortfolioConfiguration, TimeUnit, Algorithm, \
BacktestDateRange
from investing_algorithm_framework.infrastructure.database import \
teardown_sqlalchemy


class TestStrategy(TradingStrategy):
Expand Down Expand Up @@ -47,12 +49,13 @@ def setUp(self) -> None:

def tearDown(self) -> None:
super().tearDown()
teardown_sqlalchemy()

if os.path.exists(self.backtest_databases_directory):
shutil.rmtree(self.backtest_databases_directory)
shutil.rmtree(self.backtest_databases_directory, ignore_errors=True)

if os.path.exists(self.backtest_report_directory):
shutil.rmtree(self.backtest_report_directory)
shutil.rmtree(self.backtest_report_directory, ignore_errors=True)

def test_report_creation(self):
app = create_app(
Expand Down
6 changes: 4 additions & 2 deletions tests/app/test_backtesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from investing_algorithm_framework import TradingStrategy, Algorithm, \
create_app, RESOURCE_DIRECTORY, PortfolioConfiguration, \
BacktestDateRange, TimeUnit
from investing_algorithm_framework.infrastructure.database import \
teardown_sqlalchemy
from investing_algorithm_framework.domain import SQLALCHEMY_DATABASE_URI


Expand Down Expand Up @@ -70,13 +72,14 @@ def setUp(self) -> None:

def tearDown(self) -> None:
super().tearDown()
teardown_sqlalchemy()
for path in (
os.path.join(self.resource_dir, "databases"),
self.backtest_databases_dir,
self.backtest_reports_dir,
):
if os.path.exists(path):
shutil.rmtree(path)
shutil.rmtree(path, ignore_errors=True)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -253,4 +256,3 @@ def test_run_backtests(self):
risk_free_rate=0.027
)
self.assertEqual(3, len(reports))

4 changes: 2 additions & 2 deletions tests/app/test_eventloop.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def tearDown(self) -> None:
)

if os.path.exists(databases_directory):
shutil.rmtree(databases_directory)
shutil.rmtree(databases_directory, ignore_errors=True)

if os.path.exists(backtest_databases_directory):
shutil.rmtree(backtest_databases_directory)
shutil.rmtree(backtest_databases_directory, ignore_errors=True)
3 changes: 3 additions & 0 deletions tests/app/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
from investing_algorithm_framework import create_app, TradingStrategy, \
TimeUnit, PortfolioConfiguration, RESOURCE_DIRECTORY, \
Algorithm, MarketCredential
from investing_algorithm_framework.infrastructure.database import \
teardown_sqlalchemy
from tests.resources import random_string, OrderExecutorTest, \
PortfolioProviderTest

Expand Down Expand Up @@ -105,6 +107,7 @@ def setUp(self) -> None:

def tearDown(self) -> None:
super().tearDown()
teardown_sqlalchemy()
for subdir in ("databases", "backtest_databases"):
path = os.path.join(self.resource_dir, subdir)
if os.path.exists(path):
Expand Down
3 changes: 3 additions & 0 deletions tests/app/test_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from investing_algorithm_framework import create_app, TradingStrategy, \
TimeUnit, RESOURCE_DIRECTORY, PortfolioConfiguration, Algorithm, \
MarketCredential
from investing_algorithm_framework.infrastructure.database import \
teardown_sqlalchemy
from tests.resources import OrderExecutorTest, PortfolioProviderTest


Expand Down Expand Up @@ -106,6 +108,7 @@ def setUp(self) -> None:
)

def tearDown(self):
teardown_sqlalchemy()
for subdir in ("databases", "backtest_databases"):
path = os.path.join(self.resource_dir, subdir)
if os.path.exists(path):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import shutil

from investing_algorithm_framework import PortfolioConfiguration, \
MarketCredential
Expand Down Expand Up @@ -33,13 +34,7 @@ def tearDown(self) -> None:
)

if os.path.exists(database_dir):
for root, dirs, files in os.walk(database_dir, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))

os.rmdir(database_dir)
shutil.rmtree(database_dir, ignore_errors=True)

def test_list_portfolios(self):
self.iaf_app.add_strategy(StrategyOne)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import shutil

from investing_algorithm_framework import MarketCredential, \
PortfolioConfiguration, DataSource, INDEX_DATETIME
Expand Down Expand Up @@ -33,13 +34,7 @@ def tearDown(self) -> None:
)

if os.path.exists(database_dir):
for root, dirs, files in os.walk(database_dir, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))

os.rmdir(database_dir)
shutil.rmtree(database_dir, ignore_errors=True)

def test_list_portfolios(self):
strategy = StrategyOne()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import shutil

from investing_algorithm_framework import PortfolioConfiguration, \
MarketCredential
Expand Down Expand Up @@ -33,13 +34,7 @@ def tearDown(self) -> None:
)

if os.path.exists(database_dir):
for root, dirs, files in os.walk(database_dir, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))

os.rmdir(database_dir)
shutil.rmtree(database_dir, ignore_errors=True)

def test_list_portfolios(self):
self.iaf_app.add_strategy(StrategyOne)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"algorithm_id": "TestStrategy"
"algorithm_id": "BacktestTestStrategy"
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"backtest_start_date": "2023-12-01 00:00:00", "backtest_date_range_name": null, "backtest_end_date": "2023-12-02 00:00:00", "trading_symbol": "EUR", "initial_unallocated": 1000.0, "number_of_runs": 1441, "portfolio_snapshots": [{"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-01T00:00:00+00:00", "total_value": 1000.0}, {"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-02T00:00:00+00:00", "total_value": 1000.0}], "trades": [], "orders": [], "positions": [{"symbol": "EUR", "amount": 1000.0, "cost": 1000.0, "portfolio_id": 1}], "created_at": "2026-04-23 19:17:03", "symbols": [], "number_of_days": 0, "number_of_trades": 0, "number_of_trades_closed": 0, "number_of_trades_open": 0, "number_of_orders": 0, "number_of_positions": 0, "metadata": {}, "signals": {}, "signal_events": []}
{"backtest_start_date": "2023-12-01 00:00:00", "backtest_date_range_name": null, "backtest_end_date": "2023-12-02 00:00:00", "trading_symbol": "EUR", "initial_unallocated": 1000.0, "number_of_runs": 1441, "portfolio_snapshots": [{"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-01T00:00:00+00:00", "total_value": 1000.0}, {"metadata": "MetaData()", "portfolio_id": "1", "trading_symbol": "EUR", "pending_value": 0.0, "unallocated": 1000.0, "total_net_gain": 0.0, "total_revenue": 0.0, "total_cost": 0.0, "cash_flow": 0.0, "net_size": 1000.0, "created_at": "2023-12-02T00:00:00+00:00", "total_value": 1000.0}], "trades": [], "orders": [], "positions": [{"symbol": "EUR", "amount": 1000.0, "cost": 1000.0, "portfolio_id": 1}], "created_at": "2026-04-26 14:39:23", "symbols": [], "number_of_days": 0, "number_of_trades": 0, "number_of_trades_closed": 0, "number_of_trades_open": 0, "number_of_orders": 0, "number_of_positions": 0, "metadata": {}, "signals": {}, "signal_events": [], "recorded_values": {}}
Loading
Loading