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
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
- name: Setup Python
uses: actions/setup-python@v2
with:
python-version: "3.9"
python-version: "3.10"

- name: Install dependencies
run: make install
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ bktest [OPTIONS]
| `--order-files-extension` | `<extension>` | `csv` | `[csv, parquet, json]` | Change the file extension to use when listing for order files. |
| `--initial-cash` | `<amount>` | `100_000` | `number` | Change the initial cash to use for the backtesting. |
| `--quantity-mode` | `<mode>` | `percent` | `[percent, share]` | If the mode is `share`, all quantities will be interpreted as integers. If the mode is `percent`, all values will be multiplied by the current cash value. |
| `--fixed-nav` | | `false` | | When using `percent` mode, always size positions based on `--initial-cash` instead of current equity. This disables equity increase in dollars volume: after each rebalance the NAV is reset to exactly `initial_cash` by extracting profits or injecting capital as needed. Fees are captured in each period's return as `(market_gain − fees) / initial_cash`. |
| `--weekends` | | `false` | | Enable ordering on weekends. |
| `--holidays` | | `false` | | Enable ordering on holidays. |
| `--symbol-mapping` | `<mapping>` | | `path` (.json) | Specify a custom symbol mapping file enabling vendor-id translation. |
Expand Down
2 changes: 1 addition & 1 deletion bktest/__version__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
__title__ = 'bktest'
__description__ = 'bktest - A simple backtester by CrunchDAO'
__version__ = '2.1.0'
__version__ = '2.1.1'
__author__ = 'Enzo CACERES'
__author_email__ = 'enzo.caceres@crunchdao.com'
__url__ = 'https://github.com/crunchdao/backtest'
62 changes: 43 additions & 19 deletions bktest/backtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ def __init__(
price_provider: PriceProvider,
account: Account,
exporters: ExporterCollection,
fixed_nav: bool = False,
):
self.quantity_in_decimal = quantity_in_decimal
self.auto_close_others = auto_close_others
self.price_provider = price_provider
self.account = account
self.exporters = exporters
self.fixed_nav = fixed_nav

def order(
self,
Expand All @@ -49,7 +51,7 @@ def order(
others = self.account.symbols

if self.quantity_in_decimal:
equity = self.account.equity
equity = self.account.initial_cash if self.fixed_nav else self.account.equity

for order in orders:
symbol = order.symbol
Expand Down Expand Up @@ -82,25 +84,28 @@ def order(
if result.success:
others.discard(symbol)
else:
print(f"[warning] order not placed: {symbol} @ {percent}%", file=sys.stderr)
print(f"[warning] order not placed: {symbol} @ {quantity} shares", file=sys.stderr)
else:
print(f"[warning] cannot place order: {symbol} @ {quantity}x: no price available", file=sys.stderr)

if self.auto_close_others:
self._close_all(others, date, results)
self._close_all(others, price_date, results)

if self.fixed_nav and self.quantity_in_decimal:
self.account.cash = self.account.initial_cash - self.account.value

return results

def _close_all(
self,
symbols: typing.Iterable[str],
date: datetime.date,
price_date: datetime.date,
results: OrderResultCollection
):
closed, total = 0, 0

for symbol in symbols:
price = self.price_provider.get(date, symbol)
price = self.price_provider.get(price_date, symbol)
result = self.account.close_position(symbol, price)

if result.missing:
Expand All @@ -124,13 +129,11 @@ def fire_snapshot(
self,
date: datetime.date,
result: OrderResultCollection,
postponned=None
):
self.exporters.fire_snapshot(
date,
self.account,
result,
postponned
)


Expand All @@ -153,20 +156,27 @@ def __init__(
allow_weekends=False,
allow_holidays=False,
holiday_provider: HolidayProvider = LegacyHolidayProvider(),
fixed_nav: bool = False,
):
self.order_provider = order_provider
order_dates = order_provider.get_dates()
start = max(next(iter(order_dates)), start) if len(order_dates) else None

self.price_provider = PriceProvider(start, end, data_source, mapper, caching=caching)

def _make_exporter_collection(index):
ec = ExporterCollection(exporters_factory(index))
ec.configure(fixed_nav=fixed_nav)
return ec

self.pods = [
_Pod(
quantity_in_decimal,
auto_close_others,
self.price_provider,
Account(initial_cash=initial_cash, fee_model=fee_model),
ExporterCollection(exporters_factory(index))
_make_exporter_collection(index),
fixed_nav=fixed_nav,
)
for index in range(n)
]
Expand Down Expand Up @@ -197,7 +207,7 @@ def update_price(self, date):
price = cache[symbol] = self.price_provider.get(date, holding.symbol)

if price is None:
print(f"[warning] price not updated: {holding.symbol}: keeping last: {holding.price}", file=sys.stderr)
print(f"[warning] {date}: price not updated: {holding.symbol}: keeping last: {holding.price}", file=sys.stderr)
holding.up_to_date = False
else:
holding.price = price
Expand All @@ -219,25 +229,30 @@ def order(
price_date
)

if price_date:
pod.fire_snapshot(price_date, result, postponned=date)
else:
pod.fire_snapshot(date, result)
pod.fire_snapshot(price_date or date, result)

return result

def run(self):
self._fire_initialize()

for date, ordered, skips in self.date_iterator:
self.update_price(date)

ordered_in_skip = False
for skip in skips:
for pod in self.pods:
pod.exporters.fire_skip(skip.date, skip.reason, skip.ordered)

if skip.ordered:
ordered_in_skip = True
for pod in self.pods:
pod.fire_snapshot(date, None)
self.order(skip.date, price_date=date)

self.update_price(date)
if not ordered_in_skip:
for pod in self.pods:
pod.fire_snapshot(date, None)

if ordered:
self.order(date)
Expand Down Expand Up @@ -272,19 +287,23 @@ def __init__(
allow_weekends=False,
allow_holidays=False,
holiday_provider: HolidayProvider = LegacyHolidayProvider(),
fixed_nav: bool = False,
):
self.order_provider = order_provider
order_dates = order_provider.get_dates()
start = max(next(iter(order_dates)), start) if len(order_dates) else None

self.price_provider = PriceProvider(start, end, data_source, mapper, caching=caching)

exporter_collection = ExporterCollection(exporters)
exporter_collection.configure(fixed_nav=fixed_nav)
self.pod = _Pod(
quantity_in_decimal,
auto_close_others,
self.price_provider,
Account(initial_cash=initial_cash, fee_model=fee_model),
ExporterCollection(exporters)
exporter_collection,
fixed_nav=fixed_nav,
)

self.date_iterator = DateIterator(
Expand All @@ -302,7 +321,7 @@ def update_price(self, date):
price = self.price_provider.get(date, holding.symbol)

if price is None:
print(f"[warning] price not updated: {holding.symbol}: keeping last: {holding.price}", file=sys.stderr)
print(f"[warning] {date}: price not updated: {holding.symbol}: keeping last: {holding.price}", file=sys.stderr)
holding.up_to_date = False
else:
holding.price = price
Expand All @@ -325,15 +344,20 @@ def run(self):
self.exporters.fire_initialize()

for date, ordered, skips in self.date_iterator:
self.update_price(date)

ordered_in_skip = False
for skip in skips:
self.exporters.fire_skip(skip.date, skip.reason, skip.ordered)

if skip.ordered:
ordered_in_skip = True
self.exporters.fire_snapshot(date, self.account, None)
result = self.order(skip.date, price_date=date)
self.exporters.fire_snapshot(date, self.account, result, postponned=skip.date)
self.exporters.fire_snapshot(date, self.account, result)

self.update_price(date)
self.exporters.fire_snapshot(date, self.account, None)
if not ordered_in_skip:
self.exporters.fire_snapshot(date, self.account, None)

if ordered:
result = self.order(date)
Expand Down
9 changes: 8 additions & 1 deletion bktest/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
@click.option('--weekends', is_flag=True, help="Include weekends?")
@click.option('--holidays', is_flag=True, help="Include holidays?")
@click.option('--symbol-mapping', type=str, required=False, help="Custom symbol mapping file enabling vendor-id translation.")
@click.option('--fixed-nav', is_flag=True, help="Use initial cash for position sizing instead of current equity (disables compounding).")
@click.option('--no-caching', is_flag=True, help="Disable price caching.")
@click.option('--fee-model', "fee_model_value", type=str, help="Specify a fee model. Must be a constant or an expression.")
#
Expand Down Expand Up @@ -117,6 +118,7 @@ def main(
weekends,
holidays,
symbol_mapping,
fixed_nav,
no_caching,
fee_model_value,
#
Expand Down Expand Up @@ -145,6 +147,9 @@ def main(
if auto_close_others:
print("[warning] `--auto-close-others` is deprecated and is forced to `true`", file=sys.stderr)

if fixed_nav and quantity_mode != "percent":
print("[warning] `--fixed-nav` has no effect when `--quantity-mode` is not `percent`", file=sys.stderr)

now = datetime.date.today()

quantity_in_decimal = quantity_mode == "percent"
Expand Down Expand Up @@ -277,6 +282,7 @@ def main(
csv_output_file=quantstats_output_file_csv,
benchmark_ticker=quantstats_benchmark_ticker,
auto_delete=quantstats_auto_delete,
fixed_nav=fixed_nav,
))

if specific_return:
Expand Down Expand Up @@ -336,7 +342,8 @@ def main(
caching=not no_caching,
allow_weekends=weekends,
allow_holidays=holidays,
holiday_provider=holiday_provider
holiday_provider=holiday_provider,
fixed_nav=fixed_nav,
).run()


Expand Down
9 changes: 7 additions & 2 deletions bktest/export/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ def on_snapshot(self, snapshot: Snapshot) -> None:
def finalize(self) -> None:
pass

def configure(self, fixed_nav: bool) -> None:
pass


class ExporterCollection:

Expand All @@ -44,12 +47,15 @@ def fire_skip(
for exporter in self.elements:
exporter.on_skip(date, reason, ordered)

def configure(self, fixed_nav: bool) -> None:
for exporter in self.elements:
exporter.configure(fixed_nav)

def fire_snapshot(
self,
date: datetime.date,
account: "Account",
result: "OrderResultCollection",
postponned=None
):
cash = float(account.cash)
equity = float(account.equity)
Expand All @@ -58,7 +64,6 @@ def fire_snapshot(

snapshot = Snapshot(
date=date,
postponned=postponned,
cash=cash,
equity=equity,
holdings=holdings,
Expand Down
33 changes: 29 additions & 4 deletions bktest/export/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ class ConsoleDelegate(Exporter):

def __init__(self, file):
self.file = file
# Track equity across snapshots to compute period return at rebalance time.
# _last_non_ordered_equity: equity just before orders execute (pre-reset).
# _last_ordered_equity: equity just after the previous rebalance (post-reset).
self._last_non_ordered_equity: typing.Optional[float] = None
self._last_ordered_equity: typing.Optional[float] = None

def _period_return(self) -> typing.Optional[float]:
"""Return (pre_reset / prev_post_reset - 1), or None on the first rebalance."""
if self._last_non_ordered_equity is None or self._last_ordered_equity is None:
return None
return self._last_non_ordered_equity / self._last_ordered_equity - 1

def _track(self, snapshot: Snapshot) -> None:
"""Update tracking state. Must be called at the end of on_snapshot."""
if snapshot.ordered:
self._last_ordered_equity = snapshot.equity
else:
self._last_non_ordered_equity = snapshot.equity

def _print(self, content):
print(content, file=self.file)
Expand Down Expand Up @@ -63,6 +81,11 @@ def on_snapshot(self, snapshot: Snapshot) -> None:
line = f"{date} ({day}) {ordered_color}{ordered_string:20}{self.color_reset} [equity={equity:12.4f}]"

if snapshot.ordered:
period_return = self._period_return()
if period_return is not None:
ret_color = self.color_green if period_return >= 0 else self.color_red
line += f" [return={ret_color}{period_return:+.2%}{self.color_reset}]"

holding_count = snapshot.holding_count
line += f" [portfolio={holding_count:4}]"

Expand All @@ -79,15 +102,13 @@ def on_snapshot(self, snapshot: Snapshot) -> None:
closed_total = snapshot.closed_total
line += f" [closed={closed_count}/{closed_total}]"

self._track(snapshot)
self._print(line)

def _ordered_to_string(self, snapshot: Snapshot):
if snapshot.ordered:
out = "ordered"

if snapshot.postponned is not None:
out += f" ({snapshot.postponned})"

return out

return "price updated"
Expand Down Expand Up @@ -128,22 +149,26 @@ def on_skip(self, date: datetime.date, reason: str, ordered: bool) -> None:
def on_snapshot(self, snapshot: Snapshot) -> None:
self._coma()

period_return = self._period_return() if snapshot.ordered else None

self._print_json({
"event": "SNAPSHOT",
"date": str(snapshot.date),
"ordered": snapshot.ordered,
"cash": snapshot.cash,
"equity": snapshot.equity,
"postponned": str(snapshot.postponned) if snapshot.postponned else None,
"totalFees": snapshot.total_fees,
"successCount": snapshot.success_count,
"failedCount": snapshot.failed_count,
"periodReturn": period_return,
"closed": {
"count": snapshot.closed_count,
"total": snapshot.closed_total
}
})

self._track(snapshot)

@abc.abstractmethod
def finalize(self) -> None:
self._print("]")
Expand Down
3 changes: 0 additions & 3 deletions bktest/export/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,6 @@ def on_snapshot(self, snapshot: Snapshot) -> None:
date = snapshot.date
self.all_dates.add(date)

if snapshot.postponned is not None:
date = snapshot.postponned

common = [
snapshot.equity,
float(snapshot.ordered),
Expand Down
Loading