Skip to content

Commit 7600754

Browse files
authored
Merge pull request #479 from coding-kitties/291-fix-windows-unittest-database-locking
fix: resolve SQLite database file locking on Windows for unittests
2 parents 566c1c2 + 526fb1a commit 7600754

26 files changed

Lines changed: 123 additions & 64 deletions

File tree

.github/workflows/test.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
paths:
1010
- 'investing_algorithm_framework/**'
1111
- 'tests/**'
12+
- '.github/workflows/test.yml'
1213

1314
jobs:
1415
linting:
@@ -35,11 +36,11 @@ jobs:
3536
flake8 ./investing_algorithm_framework
3637
test:
3738
needs: linting
38-
timeout-minutes: 25
39+
timeout-minutes: 40
3940
strategy:
4041
fail-fast: true
4142
matrix:
42-
os: [ "ubuntu-latest", "macos-latest" ]
43+
os: [ "ubuntu-latest", "macos-latest", "windows-latest" ]
4344
python-version: [ "3.10", "3.11" ]
4445
defaults:
4546
run:
@@ -101,14 +102,12 @@ jobs:
101102
#----------------------------------------------
102103
- name: Run tests
103104
run: |
104-
source $VENV
105-
coverage run -m unittest discover -s tests
105+
poetry run coverage run -m unittest discover -s tests
106106
- name: Generate coverage report
107107
if: always()
108108
continue-on-error: true
109109
run: |
110-
source $VENV
111-
coverage xml
110+
poetry run coverage xml
112111
#----------------------------------------------
113112
# upload coverage stats
114113
#----------------------------------------------

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
</h1>
44

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

99
<h4 align="center">
@@ -61,14 +61,18 @@
6161

6262
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.
6363

64-
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.
64+
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.
6565

6666
<details open>
6767
<summary>
6868
Features
6969
</summary> <br>
7070

7171
- 📊 **30+ Metrics** — CAGR, Sharpe, Sortino, Calmar, VaR, CVaR, Max DD, Recovery & more
72+
-**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
73+
- 🏃 **Event-Driven Backtesting** — Once promising strategies are identified via vector backtests, run full event-driven backtests to simulate realistic execution and portfolio management
74+
- 🔀 **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
75+
- 🚀 **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
7276
- ⚔️ **Multi-Strategy Comparison** — Rank, filter & compare strategies in a single interactive report
7377
- 🪟 **Multi-Window Robustness** — Test across different time periods with window coverage analysis
7478
- 📈 **Equity & Drawdown Charts** — Overlay equity curves, rolling Sharpe, drawdown & return distributions

investing_algorithm_framework/app/app.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,29 @@ def initialize_storage(self, remove_database_if_exists: bool = False):
368368
logger.info(
369369
f"Removing existing database at {database_path}"
370370
)
371+
372+
# Dispose the existing engine to release file locks
373+
# (required on Windows where locks are mandatory)
374+
from investing_algorithm_framework.infrastructure.database \
375+
import Session
376+
from sqlalchemy.orm import close_all_sessions
377+
close_all_sessions()
378+
bind = Session.kw.get("bind")
379+
380+
if bind is not None:
381+
382+
try:
383+
conn = bind.connect()
384+
conn.invalidate()
385+
conn.close()
386+
except Exception:
387+
pass
388+
389+
bind.dispose()
390+
391+
import gc
392+
gc.collect()
393+
371394
os.remove(database_path)
372395

373396
# Create the sqlalchemy database uri

investing_algorithm_framework/cli/validate_backtest_checkpoints.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def validate_and_create_checkpoints(
5050
verbose_file_handle = None
5151

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

5555
def echo(msg):
5656
verbose_file_handle.write(msg + "\n")
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from .sql_alchemy import Session, setup_sqlalchemy, SQLBaseModel, \
2-
create_all_tables, clear_db, SqliteDecimal
2+
create_all_tables, clear_db, teardown_sqlalchemy, SqliteDecimal
33

44
__all__ = [
55
"Session",
66
"setup_sqlalchemy",
77
"SQLBaseModel",
88
"create_all_tables",
99
"clear_db",
10+
"teardown_sqlalchemy",
1011
"SqliteDecimal"
1112
]

investing_algorithm_framework/infrastructure/database/sql_alchemy.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from sqlalchemy import create_engine, StaticPool, String
55
from sqlalchemy import inspect
6-
from sqlalchemy.orm import DeclarativeBase, sessionmaker
6+
from sqlalchemy.orm import DeclarativeBase, sessionmaker, close_all_sessions
77
from sqlalchemy import TypeDecorator
88

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

8585

86+
def teardown_sqlalchemy():
87+
"""
88+
Dispose the engine and close all sessions to release file locks.
89+
This is essential on Windows where file locks are mandatory and
90+
prevent deletion of SQLite database files while connections are open.
91+
"""
92+
close_all_sessions()
93+
bind = Session.kw.get("bind")
94+
95+
if bind is not None:
96+
97+
# StaticPool._close_connection() is a no-op, so
98+
# engine.dispose() alone won't close the underlying DBAPI
99+
# connection. Use invalidate() which bypasses the pool's
100+
# _close_connection and calls dialect.do_close() directly,
101+
# ensuring the sqlite3 file lock is released on Windows.
102+
try:
103+
conn = bind.connect()
104+
conn.invalidate()
105+
conn.close()
106+
except Exception:
107+
pass
108+
109+
bind.dispose()
110+
111+
Session.configure(bind=None)
112+
113+
86114
from sqlalchemy import event
87115
from sqlalchemy.orm import mapper
88116
from datetime import timezone
@@ -98,6 +126,7 @@ def clear_db(db_uri):
98126
Returns:
99127
None
100128
"""
129+
engine = None
101130
# Drop all tables before deleting file
102131
try:
103132
engine = create_engine(db_uri)
@@ -107,12 +136,9 @@ def clear_db(db_uri):
107136
SQLBaseModel.metadata.drop_all(bind=engine)
108137
except Exception as e:
109138
logger.error(f"Error dropping tables: {e}")
110-
111-
# # Clear mappers (if using classical mappings)
112-
# try:
113-
# clear_mappers()
114-
# except Exception:
115-
# pass # ignore if not needed
139+
finally:
140+
if engine is not None:
141+
engine.dispose()
116142

117143

118144
@event.listens_for(mapper, "load")

tests/app/algorithm/test_run_strategy.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from investing_algorithm_framework import create_app, TradingStrategy, \
99
TimeUnit, PortfolioConfiguration, RESOURCE_DIRECTORY, Algorithm, \
1010
MarketCredential
11+
from investing_algorithm_framework.infrastructure.database import \
12+
teardown_sqlalchemy
1113
from tests.resources import random_string, OrderExecutorTest, \
1214
PortfolioProviderTest
1315

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

7476
def tearDown(self) -> None:
7577
super().tearDown()
78+
teardown_sqlalchemy()
7679
database_dir = os.path.join(self.resource_dir, "databases")
7780
if os.path.exists(database_dir):
7881
shutil.rmtree(database_dir, ignore_errors=True)

tests/app/algorithm/test_trade_price_update.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
TimeUnit, PortfolioConfiguration, RESOURCE_DIRECTORY, \
99
MarketCredential, DataSource, INDEX_DATETIME, DataType, \
1010
CSVOHLCVDataProvider, BacktestDateRange
11+
from investing_algorithm_framework.infrastructure.database import \
12+
teardown_sqlalchemy
1113
from tests.resources import random_string, \
1214
PortfolioProviderTest, OrderExecutorTest
1315

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

5658
def tearDown(self) -> None:
5759
super().tearDown()
60+
teardown_sqlalchemy()
5861
database_dir = os.path.join(self.resource_dir, "databases")
5962
if os.path.exists(database_dir):
6063
shutil.rmtree(database_dir, ignore_errors=True)

tests/app/backtesting/test_run_backtest.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from investing_algorithm_framework import create_app, RESOURCE_DIRECTORY, \
99
TradingStrategy, PortfolioConfiguration, TimeUnit, Algorithm, \
1010
BacktestDateRange
11+
from investing_algorithm_framework.infrastructure.database import \
12+
teardown_sqlalchemy
1113

1214

1315
class TestStrategy(TradingStrategy):
@@ -47,12 +49,13 @@ def setUp(self) -> None:
4749

4850
def tearDown(self) -> None:
4951
super().tearDown()
52+
teardown_sqlalchemy()
5053

5154
if os.path.exists(self.backtest_databases_directory):
52-
shutil.rmtree(self.backtest_databases_directory)
55+
shutil.rmtree(self.backtest_databases_directory, ignore_errors=True)
5356

5457
if os.path.exists(self.backtest_report_directory):
55-
shutil.rmtree(self.backtest_report_directory)
58+
shutil.rmtree(self.backtest_report_directory, ignore_errors=True)
5659

5760
def test_report_creation(self):
5861
app = create_app(

tests/app/test_backtesting.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
from investing_algorithm_framework import TradingStrategy, Algorithm, \
1919
create_app, RESOURCE_DIRECTORY, PortfolioConfiguration, \
2020
BacktestDateRange, TimeUnit
21+
from investing_algorithm_framework.infrastructure.database import \
22+
teardown_sqlalchemy
2123
from investing_algorithm_framework.domain import SQLALCHEMY_DATABASE_URI
2224

2325

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

7173
def tearDown(self) -> None:
7274
super().tearDown()
75+
teardown_sqlalchemy()
7376
for path in (
7477
os.path.join(self.resource_dir, "databases"),
7578
self.backtest_databases_dir,
7679
self.backtest_reports_dir,
7780
):
7881
if os.path.exists(path):
79-
shutil.rmtree(path)
82+
shutil.rmtree(path, ignore_errors=True)
8083

8184

8285
# ---------------------------------------------------------------------------
@@ -253,4 +256,3 @@ def test_run_backtests(self):
253256
risk_free_rate=0.027
254257
)
255258
self.assertEqual(3, len(reports))
256-

0 commit comments

Comments
 (0)