Skip to content

Commit 117faeb

Browse files
authored
Merge pull request #424 from coding-kitties/feature/sqlite-decimal-precision
fix: sqlite decimal precision + portfolio sync bug fixes
2 parents 96095a1 + ddd5b5e commit 117faeb

28 files changed

Lines changed: 1416 additions & 345 deletions

File tree

investing_algorithm_framework/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
PortfolioConfiguration, RESOURCE_DIRECTORY, AWS_LAMBDA_LOGGING_CONFIG, \
1919
Trade, APP_MODE, AppMode, DATETIME_FORMAT, load_backtests_from_directory, \
2020
BacktestDateRange, convert_polars_to_pandas, BacktestRun, \
21-
DEFAULT_LOGGING_CONFIG, DataType, DataProvider, StopLossRule, ScalingRule, TradingCost, \
21+
DEFAULT_LOGGING_CONFIG, DataType, DataProvider, StopLossRule, \
22+
ScalingRule, TradingCost, \
2223
TradeStatus, generate_backtest_summary_metrics, generate_algorithm_id, \
2324
APPLICATION_DIRECTORY, DataSource, OrderExecutor, PortfolioProvider, \
2425
SnapshotInterval, AWS_S3_STATE_BUCKET_NAME, BacktestEvaluationFocus, \

investing_algorithm_framework/cli/mcp_server.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2297,7 +2297,10 @@ def main(directory: Optional[Union[str, List[str]]] = None):
22972297
),
22982298
)
22992299
args = parser.parse_args()
2300-
dirs = args.directory or ([directory] if isinstance(directory, str) else directory) or []
2300+
dirs = args.directory or (
2301+
[directory] if isinstance(directory, str)
2302+
else directory
2303+
) or []
23012304

23022305
if len(dirs) == 1:
23032306
server = BacktestMCPServer(dirs[0])

investing_algorithm_framework/domain/models/risk_rules/scaling_rule.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,9 @@ def __init__(
6969
if isinstance(scale_in_percentage, (int, float)):
7070
self._scale_in_percentages = [float(scale_in_percentage)]
7171
else:
72-
self._scale_in_percentages = [float(v) for v in scale_in_percentage]
72+
self._scale_in_percentages = [
73+
float(v) for v in scale_in_percentage
74+
]
7375

7476
if isinstance(scale_out_percentage, (int, float)):
7577
self._scale_out_percentages = [float(scale_out_percentage)]
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from .sql_alchemy import Session, setup_sqlalchemy, SQLBaseModel, \
2-
create_all_tables, clear_db
2+
create_all_tables, clear_db, SqliteDecimal
33

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

investing_algorithm_framework/infrastructure/database/sql_alchemy.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import logging
2+
from decimal import Decimal
23

3-
from sqlalchemy import create_engine, StaticPool
4+
from sqlalchemy import create_engine, StaticPool, String
45
from sqlalchemy import inspect
56
from sqlalchemy.orm import DeclarativeBase, sessionmaker
7+
from sqlalchemy import TypeDecorator
68

79
from investing_algorithm_framework.domain import SQLALCHEMY_DATABASE_URI, \
810
OperationalException
@@ -11,6 +13,39 @@
1113
logger = logging.getLogger("investing_algorithm_framework")
1214

1315

16+
class SqliteDecimal(TypeDecorator):
17+
"""
18+
A type that stores Python numeric values as TEXT in SQLite for
19+
exact precision. This avoids the lossy float conversion that
20+
occurs with SQLAlchemy's Float/Numeric types on SQLite.
21+
22+
- On write: converts the value to its full-precision string
23+
representation via Decimal, then stores as TEXT.
24+
- On read: returns a Python float for backward compatibility
25+
with existing arithmetic code.
26+
27+
The key benefit is lossless **storage**: the TEXT column preserves
28+
the exact decimal representation. For example, a value like
29+
12345678901234.567890123456789 is stored as that exact string
30+
rather than being silently truncated to a 64-bit float.
31+
32+
Use this instead of Column(Float) for monetary values, balances,
33+
prices, and amounts where storage precision matters.
34+
"""
35+
impl = String
36+
cache_ok = True
37+
38+
def process_bind_param(self, value, dialect):
39+
if value is not None:
40+
return str(Decimal(str(value)))
41+
return None
42+
43+
def process_result_value(self, value, dialect):
44+
if value is not None:
45+
return float(Decimal(value))
46+
return None
47+
48+
1449
class SQLAlchemyAdapter:
1550

1651
def __init__(self, app):

investing_algorithm_framework/infrastructure/models/order/order.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import logging
22
from datetime import datetime, timezone
33

4-
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Float
4+
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
55
from sqlalchemy.orm import relationship
66

77
from investing_algorithm_framework.domain import OrderType, \
88
OrderSide, Order, OrderStatus
9-
from investing_algorithm_framework.infrastructure.database import SQLBaseModel
9+
from investing_algorithm_framework.infrastructure.database import (
10+
SQLBaseModel, SqliteDecimal
11+
)
1012
from investing_algorithm_framework.infrastructure.models.model_extension \
1113
import SQLAlchemyModelExtension
1214
from investing_algorithm_framework.infrastructure.models.\
@@ -33,21 +35,21 @@ class SQLOrder(Order, SQLBaseModel, SQLAlchemyModelExtension):
3335
trades = relationship(
3436
'SQLTrade', secondary=order_trade_association, back_populates='orders'
3537
)
36-
price = Column(Float)
37-
amount = Column(Float)
38-
remaining = Column(Float, default=None)
39-
filled = Column(Float, default=None)
40-
cost = Column(Float, default=0)
38+
price = Column(SqliteDecimal())
39+
amount = Column(SqliteDecimal())
40+
remaining = Column(SqliteDecimal(), default=None)
41+
filled = Column(SqliteDecimal(), default=None)
42+
cost = Column(SqliteDecimal(), default=0)
4143
status = Column(String, default=OrderStatus.CREATED.value)
4244
position_id = Column(Integer, ForeignKey('positions.id'))
4345
position = relationship("SQLPosition", back_populates="orders")
4446
created_at = Column(DateTime(timezone=True), default=utcnow)
4547
updated_at = Column(
4648
DateTime(timezone=True), default=utcnow, onupdate=utcnow
4749
)
48-
order_fee = Column(Float, default=None)
50+
order_fee = Column(SqliteDecimal(), default=None)
4951
order_fee_currency = Column(String, default=None)
50-
order_fee_rate = Column(Float, default=None)
52+
order_fee_rate = Column(SqliteDecimal(), default=None)
5153
sell_order_metadata_id = Column(Integer, ForeignKey('orders.id'))
5254
trade_allocations = relationship(
5355
'SQLTradeAllocation', back_populates='order'

investing_algorithm_framework/infrastructure/models/order/trade_allocation.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
22

3-
from sqlalchemy import Column, Integer, ForeignKey, Float
3+
from sqlalchemy import Column, Integer, ForeignKey
44
from sqlalchemy.orm import relationship
55

6-
from investing_algorithm_framework.infrastructure.database import SQLBaseModel
6+
from investing_algorithm_framework.infrastructure.database import (
7+
SQLBaseModel, SqliteDecimal
8+
)
79
from investing_algorithm_framework.infrastructure.models.model_extension \
810
import SQLAlchemyModelExtension
911

@@ -55,13 +57,13 @@ class SQLTradeAllocation(SQLBaseModel, SQLAlchemyModelExtension):
5557
trade_id = Column(Integer)
5658
stop_loss_id = Column(Integer)
5759
take_profit_id = Column(Integer)
58-
amount = Column(Float)
59-
amount_pending = Column(Float)
60-
open_price = Column(Float, default=0)
61-
close_price = Column(Float, default=0)
62-
buy_fee = Column(Float, default=0)
63-
sell_fee = Column(Float, default=0)
64-
net_gain_contribution = Column(Float, default=0)
60+
amount = Column(SqliteDecimal())
61+
amount_pending = Column(SqliteDecimal())
62+
open_price = Column(SqliteDecimal(), default=0)
63+
close_price = Column(SqliteDecimal(), default=0)
64+
buy_fee = Column(SqliteDecimal(), default=0)
65+
sell_fee = Column(SqliteDecimal(), default=0)
66+
net_gain_contribution = Column(SqliteDecimal(), default=0)
6567

6668
def __init__(
6769
self,

investing_algorithm_framework/infrastructure/models/portfolio/portfolio_snapshot.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from sqlalchemy import Column, Integer, String, DateTime, Float
1+
from sqlalchemy import Column, Integer, String, DateTime
22
from sqlalchemy.orm import relationship
33

44
from investing_algorithm_framework.domain import PortfolioSnapshot
5-
from investing_algorithm_framework.infrastructure.database import SQLBaseModel
5+
from investing_algorithm_framework.infrastructure.database import (
6+
SQLBaseModel, SqliteDecimal
7+
)
68
from investing_algorithm_framework.infrastructure.models.model_extension \
79
import SQLAlchemyModelExtension
810

@@ -20,14 +22,14 @@ class SQLPortfolioSnapshot(
2022
id = Column(Integer, primary_key=True)
2123
portfolio_id = Column(String, nullable=False)
2224
trading_symbol = Column(String, nullable=False)
23-
pending_value = Column(Float, nullable=False, default=0)
24-
unallocated = Column(Float, nullable=False, default=0)
25-
net_size = Column(Float, nullable=False, default=0)
26-
total_net_gain = Column(Float, nullable=False, default=0)
27-
total_revenue = Column(Float, nullable=False, default=0)
28-
total_cost = Column(Float, nullable=False, default=0)
29-
total_value = Column(Float, nullable=False, default=0)
30-
cash_flow = Column(Float, nullable=False, default=0)
25+
pending_value = Column(SqliteDecimal(), nullable=False, default=0)
26+
unallocated = Column(SqliteDecimal(), nullable=False, default=0)
27+
net_size = Column(SqliteDecimal(), nullable=False, default=0)
28+
total_net_gain = Column(SqliteDecimal(), nullable=False, default=0)
29+
total_revenue = Column(SqliteDecimal(), nullable=False, default=0)
30+
total_cost = Column(SqliteDecimal(), nullable=False, default=0)
31+
total_value = Column(SqliteDecimal(), nullable=False, default=0)
32+
cash_flow = Column(SqliteDecimal(), nullable=False, default=0)
3133
created_at = Column(DateTime, nullable=False, default=0)
3234
position_snapshots = relationship(
3335
"SQLPositionSnapshot",

investing_algorithm_framework/infrastructure/models/portfolio/sql_portfolio.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from datetime import datetime, timezone
22

3-
from sqlalchemy import Column, Integer, String, DateTime, Float, Boolean
3+
from sqlalchemy import Column, Integer, String, DateTime, Boolean
44
from sqlalchemy import UniqueConstraint
55
from sqlalchemy.orm import relationship
66
from sqlalchemy.orm import validates
77

88
from investing_algorithm_framework.domain import Portfolio
9-
from investing_algorithm_framework.infrastructure.database import SQLBaseModel
9+
from investing_algorithm_framework.infrastructure.database import (
10+
SQLBaseModel, SqliteDecimal
11+
)
1012
from investing_algorithm_framework.infrastructure.models.model_extension \
1113
import SQLAlchemyModelExtension
1214

@@ -16,14 +18,14 @@ class SQLPortfolio(Portfolio, SQLBaseModel, SQLAlchemyModelExtension):
1618
id = Column(Integer, primary_key=True)
1719
identifier = Column(String, nullable=False, unique=True)
1820
trading_symbol = Column(String, nullable=False)
19-
realized = Column(Float, nullable=False, default=0)
20-
total_revenue = Column(Float, nullable=False, default=0)
21-
total_cost = Column(Float, nullable=False, default=0)
22-
total_net_gain = Column(Float, nullable=False, default=0)
23-
total_trade_volume = Column(Float, nullable=False, default=0)
24-
net_size = Column(Float, nullable=False, default=0)
25-
unallocated = Column(Float, nullable=False, default=0)
26-
initial_balance = Column(Float, nullable=True)
21+
realized = Column(SqliteDecimal(), nullable=False, default=0)
22+
total_revenue = Column(SqliteDecimal(), nullable=False, default=0)
23+
total_cost = Column(SqliteDecimal(), nullable=False, default=0)
24+
total_net_gain = Column(SqliteDecimal(), nullable=False, default=0)
25+
total_trade_volume = Column(SqliteDecimal(), nullable=False, default=0)
26+
net_size = Column(SqliteDecimal(), nullable=False, default=0)
27+
unallocated = Column(SqliteDecimal(), nullable=False, default=0)
28+
initial_balance = Column(SqliteDecimal(), nullable=True)
2729
market = Column(String, nullable=False)
2830
positions = relationship(
2931
"SQLPosition",

investing_algorithm_framework/infrastructure/models/position/position.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
from sqlalchemy import Column, Integer, String, ForeignKey, Float
1+
from sqlalchemy import Column, Integer, String, ForeignKey
22
from sqlalchemy import UniqueConstraint
33
from sqlalchemy.orm import relationship, validates
44

55
from investing_algorithm_framework.domain import Position
6-
from investing_algorithm_framework.infrastructure.database import SQLBaseModel
6+
from investing_algorithm_framework.infrastructure.database import (
7+
SQLBaseModel, SqliteDecimal
8+
)
79
from investing_algorithm_framework.infrastructure.models.model_extension \
810
import SQLAlchemyModelExtension
911

@@ -12,8 +14,8 @@ class SQLPosition(SQLBaseModel, Position, SQLAlchemyModelExtension):
1214
__tablename__ = "positions"
1315
id = Column(Integer, primary_key=True, unique=True)
1416
symbol = Column(String)
15-
amount = Column(Float)
16-
cost = Column(Float)
17+
amount = Column(SqliteDecimal())
18+
cost = Column(SqliteDecimal())
1719
orders = relationship(
1820
"SQLOrder",
1921
back_populates="position",

0 commit comments

Comments
 (0)