Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add exchange hours handling #556

Merged
merged 1 commit into from
Jan 19, 2025
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,6 @@ cython_debug/

# Ignore dotenv/direnv files
.env*

# Ignore notebooks
*.ipynb
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies = [
"pydantic>=2.10.2,<3",
"annotated-types>=0.7.0,<0.8",
"polyfactory>=2.18.1,<3",
"exchange-calendars>=4.8",
]
description = "ThetaGang is an IBKR bot for getting money"
license = "AGPL-3.0-only"
Expand Down
38 changes: 38 additions & 0 deletions thetagang.toml
Original file line number Diff line number Diff line change
Expand Up @@ -616,3 +616,41 @@ sell_threshold = 10000
# ["noTakeLiq", "1"],
]
strategy = "Vwap"

[exchange_hours]
# ThetaGang can check whether the market is open before running. This is useful
# to avoid placing orders when the market is closed. We can also (for example)
# avoid running too early (i.e., immediately after market open) to avoid the
# initial volatility, or too late (i.e., immediately before market close) to
# avoid the closing volatility.

# The exchange to check for market hours. XNYS is the NYSE, but you can use any
# of the ISO exchange codes from
# https://github.com/gerrymanoim/exchange_calendars?tab=readme-ov-file#Calendars.
#
# For example, you'd use "XTKS" for the Tokyo Stock Exchange, "XLON" for the
# London Stock Exchange, or "XHKG" for the Hong Kong Stock Exchange.
exchange = "XNYS"

# If the market is closed, we can either exit immediately, wait until it's
# time to begin, or continue as normal. Specify "exit" to exit, "wait" to wait,
# and "continue" to continue.
#
# Setting this to "continue" is equivalent to disabling this feature.
action_when_closed = "exit"

# The maximum amount of time, in seconds, to wait for the market to open if you
# set `exchange_hours.action_when_closed = "wait"`. For example, a value of 3600
# means ThetaGang will only wait for the market to open if it's within 1 hour of
# the current time.
max_wait_until_open = 3600

# The amount of time, in seconds, after the market open, to wait before running
# ThetaGang. For example, if this is set to 1800, ThetaGang will consider the
# market closed until 30 minutes after the open.
delay_after_open = 1800

# The amount of time, in seconds, before the market close, to stop running
# ThetaGang. For example, if set to 1800, ThetaGang will consider the market
# closed 30 minutes prior to the actual close.
delay_before_close = 1800
25 changes: 25 additions & 0 deletions thetagang/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import math
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Tuple

from pydantic import BaseModel, Field, model_validator
Expand Down Expand Up @@ -469,11 +470,34 @@ class Puts(BaseModel):
no_trading: Optional[bool] = None


class ActionWhenClosedEnum(str, Enum):
wait = "wait"
exit = "exit"
continue_ = "continue"


class ExchangeHoursConfig(BaseModel, DisplayMixin):
exchange: str = Field(default="XNYS")
action_when_closed: ActionWhenClosedEnum = Field(default=ActionWhenClosedEnum.exit)
delay_after_open: int = Field(default=1800, ge=0)
delay_before_close: int = Field(default=1800, ge=0)
max_wait_until_open: int = Field(default=3600, ge=0)

def add_to_table(self, table: Table, section: str = "") -> None:
table.add_row("[spring_green1]Exchange hours")
table.add_row("", "Exchange", "=", self.exchange)
table.add_row("", "Action when closed", "=", self.action_when_closed)
table.add_row("", "Delay after open", "=", f"{self.delay_after_open}s")
table.add_row("", "Delay before close", "=", f"{self.delay_before_close}s")
table.add_row("", "Max wait until open", "=", f"{self.max_wait_until_open}s")


class Config(BaseModel, DisplayMixin):
account: AccountConfig
option_chains: OptionChainsConfig
roll_when: RollWhenConfig
target: TargetConfig
exchange_hours: ExchangeHoursConfig = Field(default_factory=ExchangeHoursConfig)

orders: OrdersConfig = Field(default_factory=OrdersConfig)
ib_async: IBAsyncConfig = Field(default_factory=IBAsyncConfig)
Expand Down Expand Up @@ -636,6 +660,7 @@ def display(self, config_path: str) -> None:

# Add all component tables
self.account.add_to_table(config_table)
self.exchange_hours.add_to_table(config_table)
if self.constants:
self.constants.add_to_table(config_table)
self.orders.add_to_table(config_table)
Expand Down
77 changes: 77 additions & 0 deletions thetagang/exchange_hours.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import time
from datetime import datetime, timezone

import exchange_calendars as xcals
import pandas as pd

from thetagang import log
from thetagang.config import ExchangeHoursConfig


def determine_action(config: ExchangeHoursConfig, now: datetime) -> str:
if config.action_when_closed == "continue":
return "continue"

calendar = xcals.get_calendar(config.exchange)
today = now.date()

if calendar.is_session(today): # type: ignore
open = calendar.session_open(today) # type: ignore
close = calendar.session_close(today) # type: ignore

start = open + pd.Timedelta(seconds=config.delay_after_open)
end = close - pd.Timedelta(seconds=config.delay_before_close)

log.info(f"Exchange hours open={open}, close={close}, start={start}, end={end}")

if start <= now <= end:
# Exchange is open
return "continue"
elif config.action_when_closed == "exit":
log.info("Exchange is closed")
return "exit"
elif config.action_when_closed == "wait":
log.info("Exchange is closed")
return "wait"
elif config.action_when_closed == "wait":
return "wait"

log.info("Exchange is closed")
return "exit"


def waited_for_open(config: ExchangeHoursConfig, now: datetime) -> bool:
calendar = xcals.get_calendar(config.exchange)
today = now.date()

next_session = calendar.date_to_session(today, direction="next") # type: ignore

open = calendar.session_open(next_session) # type: ignore
start = open + pd.Timedelta(seconds=config.delay_after_open)

seconds_until_start = (start - now).total_seconds()

if seconds_until_start < config.max_wait_until_open:
log.info(
f"Waiting for exchange to open, start={start} seconds_until_start={seconds_until_start}"
)
time.sleep(seconds_until_start)
return True
else:
log.info(
f"Max wait time exceeded, exiting (seconds_until_start={seconds_until_start}, max_wait_until_open={config.max_wait_until_open})"
)

return False


def need_to_exit(config: ExchangeHoursConfig) -> bool:
now = datetime.now(tz=timezone.utc)
action = determine_action(config, now)
if action == "exit":
return True
if action == "wait":
return not waited_for_open(config, now)

# action is "continue"
return False
104 changes: 104 additions & 0 deletions thetagang/test_exchange_hours.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from datetime import datetime, timezone
from unittest.mock import patch

from thetagang.config import ActionWhenClosedEnum, ExchangeHoursConfig
from thetagang.exchange_hours import determine_action, waited_for_open


def test_determine_action_continue_when_closed():
config = ExchangeHoursConfig(
exchange="XNYS",
delay_after_open=0,
delay_before_close=0,
action_when_closed=ActionWhenClosedEnum.continue_,
)
now = datetime(2025, 1, 21, 12, 0, tzinfo=timezone.utc)

result = determine_action(config, now)
assert result == "continue"


def test_determine_action_in_open_window():
config = ExchangeHoursConfig(
exchange="XNYS",
delay_after_open=60,
delay_before_close=60,
action_when_closed=ActionWhenClosedEnum.continue_,
)
now = datetime(2025, 1, 21, 15, 0, tzinfo=timezone.utc)

result = determine_action(config, now)
assert result == "continue"


def test_determine_action_after_close():
config = ExchangeHoursConfig(
exchange="XNYS",
delay_after_open=60,
delay_before_close=60,
action_when_closed=ActionWhenClosedEnum.exit,
)
now = datetime(2025, 1, 21, 21, 0, tzinfo=timezone.utc)

result = determine_action(config, now)
assert result == "exit"


def test_determine_action_session_closed_wait():
config = ExchangeHoursConfig(
exchange="XNYS",
delay_after_open=60,
delay_before_close=60,
action_when_closed=ActionWhenClosedEnum.wait,
)
now = datetime(2025, 1, 21, 14, 29, tzinfo=timezone.utc)

result = determine_action(config, now)
assert result == "wait"


@patch("thetagang.exchange_hours.time.sleep")
def test_waited_for_open_under_max(mock_sleep):
config = ExchangeHoursConfig(
exchange="XNYS",
delay_after_open=60,
delay_before_close=60,
action_when_closed=ActionWhenClosedEnum.wait,
max_wait_until_open=600,
)
now = datetime(2025, 1, 21, 14, 29, tzinfo=timezone.utc)

assert waited_for_open(config, now) is True
mock_sleep.assert_called_once()


@patch("thetagang.exchange_hours.time.sleep")
def test_waited_for_open_exceeds_max(mock_sleep):
config = ExchangeHoursConfig(
exchange="XNYS",
delay_after_open=60,
delay_before_close=60,
action_when_closed=ActionWhenClosedEnum.wait,
max_wait_until_open=30,
)
now = datetime(2025, 1, 21, 14, 0, tzinfo=timezone.utc)

assert waited_for_open(config, now) is False
mock_sleep.assert_not_called()


@patch("thetagang.exchange_hours.time.sleep")
def test_waited_for_open_negative_difference(mock_sleep):
config = ExchangeHoursConfig(
exchange="XNYS",
delay_after_open=60,
delay_before_close=60,
action_when_closed=ActionWhenClosedEnum.wait,
max_wait_until_open=300,
)
# 'now' is already after the start time
now = datetime(2025, 1, 21, 15, 0, tzinfo=timezone.utc)

# seconds_until_start will be negative, but code checks if it's < max_wait_until_open
assert waited_for_open(config, now) is True
mock_sleep.assert_called_once()
5 changes: 5 additions & 0 deletions thetagang/thetagang.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from thetagang import log
from thetagang.config import Config, normalize_config
from thetagang.exchange_hours import need_to_exit
from thetagang.portfolio_manager import PortfolioManager

util.patchAsyncio()
Expand All @@ -24,6 +25,10 @@ def start(config_path: str, without_ibc: bool = False, dry_run: bool = False) ->
if config.ib_async.logfile:
util.logToFile(config.ib_async.logfile)

# Check if exchange is open before continuing
if need_to_exit(config.exchange_hours):
return

async def onConnected() -> None:
log.info(f"Connected to IB Gateway, serverVersion={ib.client.serverVersion()}")
await portfolio_manager.manage()
Expand Down
Loading
Loading