schwab-py-utils 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.3
2
+ Name: schwab-py-utils
3
+ Version: 0.1.0
4
+ Summary: Quality-of-life utilities for schwab-py
5
+ Requires-Dist: schwab-py
6
+ Requires-Dist: pandas<3
7
+ Requires-Dist: envchain-python ; extra == 'envchain'
8
+ Requires-Python: >=3.11
9
+ Provides-Extra: envchain
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["uv_build"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "schwab-py-utils"
7
+ version = "0.1.0"
8
+ description = "Quality-of-life utilities for schwab-py"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "schwab-py",
12
+ "pandas<3",
13
+ # httpx is already a dependency of schwab-py, so we can use it in our utils without adding it to our own dependencies
14
+ # "httpx",
15
+ ]
16
+ [project.optional-dependencies]
17
+ envchain = ["envchain-python"]
18
+
19
+ [dependency-groups]
20
+ dev = ["pytest>=8.0", "python-dotenv>=1.0"]
21
+
22
+ [tool.uv.build-backend]
23
+ module-root = "src"
24
+
25
+ [tool.pytest.ini_options]
26
+ testpaths = ["tests"]
27
+ markers = [
28
+ "integration: tests that require real credentials/network and are opt-in",
29
+ ]
30
+ schwab_live_token_path = "~/testdir/unusualwhales-python/.credentials/token.json"
31
+ schwab_live_auth_method = "easy_client"
32
+ schwab_live_symbol = "AAPL"
@@ -0,0 +1,67 @@
1
+ """
2
+ schwab-py-utils: Quality-of-life utilities for schwab-py.
3
+
4
+ Example usage:
5
+ from schwab_py_utils import get_client, get_price_history, normalize_ticker, Client, AsyncClient
6
+
7
+ client = get_client()
8
+ df = get_price_history(client, "AAPL", interval=5)
9
+ """
10
+
11
+ from schwab.client import AsyncClient, Client
12
+
13
+ from ._types import (
14
+ EquityTickerClassStyle,
15
+ SchwabPriceHistoryInterval,
16
+ SchwabPriceHistoryIntervalStr,
17
+ Ticker,
18
+ TickerNormalized,
19
+ TickerUpperCase,
20
+ )
21
+ from .client import (
22
+ SchwabClientMethod,
23
+ SchwabPyGetClientMethod,
24
+ get_client,
25
+ get_schwab_client,
26
+ )
27
+ from .price_history import (
28
+ get_price_history,
29
+ get_price_history_from_schwab,
30
+ get_price_history_from_schwab_non_df,
31
+ get_price_history_raw,
32
+ price_history_to_df,
33
+ )
34
+ from .ticker import (
35
+ DEFAULT_DOLLAR_PREFIX_TICKERS,
36
+ add_dollar_prefix_tickers,
37
+ dollar_prefix_tickers,
38
+ normalize_ticker,
39
+ )
40
+
41
+ __all__ = [
42
+ # Client
43
+ "Client",
44
+ "AsyncClient",
45
+ "get_client",
46
+ "get_schwab_client",
47
+ "SchwabClientMethod",
48
+ "SchwabPyGetClientMethod",
49
+ # Price history
50
+ "get_price_history",
51
+ "get_price_history_from_schwab",
52
+ "get_price_history_raw",
53
+ "get_price_history_from_schwab_non_df",
54
+ "price_history_to_df",
55
+ # Ticker
56
+ "normalize_ticker",
57
+ "add_dollar_prefix_tickers",
58
+ "dollar_prefix_tickers",
59
+ "DEFAULT_DOLLAR_PREFIX_TICKERS",
60
+ # Types
61
+ "Ticker",
62
+ "TickerUpperCase",
63
+ "TickerNormalized",
64
+ "EquityTickerClassStyle",
65
+ "SchwabPriceHistoryInterval",
66
+ "SchwabPriceHistoryIntervalStr",
67
+ ]
@@ -0,0 +1,9 @@
1
+ from typing import Literal
2
+
3
+ Ticker = str
4
+ TickerUpperCase = str
5
+ TickerNormalized = str
6
+ EquityTickerClassStyle = Literal[".", "/"]
7
+
8
+ SchwabPriceHistoryInterval = Literal[1, 5, 10, 15, 30, "d", "w"]
9
+ SchwabPriceHistoryIntervalStr = Literal["1", "5", "10", "15", "30", "d", "w"]
@@ -0,0 +1,114 @@
1
+ """
2
+ Envchain-based token storage for schwab-py.
3
+
4
+ Requires the `envchain` optional dependency:
5
+ pip install schwab-py-utils[envchain]
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from typing import Any, Callable
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ TokenReadFunc = Callable[[], dict[str, Any] | None]
15
+ TokenWriteFunc = Callable[[dict[str, Any]], None]
16
+
17
+
18
+ def create_envchain_token_handlers(
19
+ envchain_namespace: str = "schwab-py",
20
+ envchain_key: str = "token",
21
+ envchain_cmd: str = "/opt/homebrew/bin/envchain",
22
+ ) -> tuple[TokenReadFunc, TokenWriteFunc]:
23
+ """
24
+ Create token read/write functions that use envchain for secure storage.
25
+
26
+ The returned functions are suitable for use with schwab-py's
27
+ `client_from_access_functions`.
28
+
29
+ Args:
30
+ envchain_namespace: The namespace in envchain for token storage
31
+ envchain_key: The key within the namespace
32
+ envchain_cmd: Path to the envchain command
33
+
34
+ Returns:
35
+ Tuple of (token_read_func, token_write_func)
36
+
37
+ Raises:
38
+ ImportError: If envchain-python is not installed
39
+ """
40
+ try:
41
+ from envchain_python import ( # pyright: ignore[reportMissingImports]
42
+ EnvchainError,
43
+ deserialize_string_to_object,
44
+ get_var,
45
+ serialize_object_to_string,
46
+ set_var,
47
+ )
48
+ except ImportError:
49
+ raise ImportError(
50
+ "envchain-python is required for envchain token storage. "
51
+ "Install with: pip install schwab-py-utils[envchain]"
52
+ )
53
+
54
+ if not isinstance(envchain_namespace, str) or not envchain_namespace.strip():
55
+ raise ValueError("envchain_namespace must be a non-empty string.")
56
+ if not isinstance(envchain_key, str) or not envchain_key.strip():
57
+ raise ValueError("envchain_key must be a non-empty string.")
58
+
59
+ envchain_kwargs = {"envchain_cmd": envchain_cmd}
60
+
61
+ def read_func() -> dict[str, Any] | None:
62
+ """Read token from envchain."""
63
+ logger.debug(
64
+ f"Reading token from envchain: namespace='{envchain_namespace}', key='{envchain_key}'"
65
+ )
66
+ try:
67
+ token_str = get_var(envchain_namespace, envchain_key, **envchain_kwargs)
68
+
69
+ if token_str is None or not token_str:
70
+ logger.info(
71
+ f"No token found in envchain for namespace='{envchain_namespace}'"
72
+ )
73
+ return None
74
+
75
+ token_obj = deserialize_string_to_object(token_str)
76
+ if not isinstance(token_obj, dict):
77
+ logger.warning(f"Deserialized token is not a dict: {type(token_obj)}")
78
+ return None
79
+
80
+ logger.debug("Successfully read token from envchain.")
81
+ return token_obj
82
+
83
+ except EnvchainError as e:
84
+ logger.warning(f"EnvchainError reading token: {e}")
85
+ return None
86
+ except json.JSONDecodeError as e:
87
+ logger.error(f"Failed to deserialize token: {e}")
88
+ return None
89
+
90
+ def write_func(
91
+ metadata_wrapped_token: dict[str, Any], *args: Any, **kwargs: Any
92
+ ) -> None:
93
+ """Write token to envchain."""
94
+ logger.debug(
95
+ f"Writing token to envchain: namespace='{envchain_namespace}', key='{envchain_key}'"
96
+ )
97
+ if not isinstance(metadata_wrapped_token, dict):
98
+ raise TypeError(f"Expected dict, got {type(metadata_wrapped_token)}")
99
+
100
+ try:
101
+ serialized = serialize_object_to_string(metadata_wrapped_token)
102
+ set_var(
103
+ envchain_namespace,
104
+ envchain_key,
105
+ serialized,
106
+ require_passphrase=True,
107
+ **envchain_kwargs,
108
+ )
109
+ logger.debug("Successfully wrote token to envchain.")
110
+ except EnvchainError as e:
111
+ logger.error(f"EnvchainError writing token: {e}")
112
+ raise
113
+
114
+ return read_func, write_func
@@ -0,0 +1,256 @@
1
+ import os
2
+ from enum import Enum
3
+ from functools import cache
4
+ from pathlib import Path
5
+ from typing import Literal, overload
6
+
7
+ from schwab.client import AsyncClient, Client
8
+
9
+
10
+ DEFAULT_TOKEN_PATH = Path.home() / ".credentials" / "schwab-token.json"
11
+
12
+ AUTH_METHOD_ENV_VAR = "SCHWAB_PY_UTILS_AUTH_METHOD"
13
+ TOKEN_PATH_ENV_VAR = "SCHWAB_PY_UTILS_TOKEN_PATH"
14
+ ENVCHAIN_CMD_ENV_VAR = "SCHWAB_PY_UTILS_ENVCHAIN_CMD"
15
+
16
+ _TOKEN_ONLY_PLACEHOLDER_API_KEY = "TOKEN_ONLY"
17
+ _TOKEN_ONLY_PLACEHOLDER_SECRET = "TOKEN_ONLY"
18
+
19
+
20
+ class SchwabClientMethod(Enum):
21
+ """
22
+ Methods to instantiate a Schwab API client:
23
+
24
+ - easy_client: Use schwab-py's easy_client with file-based token storage
25
+ - envchain_token: Fetch OAuth token via envchain (requires envchain-python)
26
+ - envchain: Fetch OAuth token + credentials via envchain (requires envchain-python)
27
+ """
28
+
29
+ easy_client = "easy_client"
30
+ envchain_token = "envchain_token"
31
+ envchain = "envchain"
32
+
33
+
34
+ def _get_env_credentials() -> tuple[str, str, str]:
35
+ """Get Schwab API credentials from environment variables."""
36
+ api_key = os.getenv("SCHWAB_API_KEY")
37
+ app_secret = os.getenv("SCHWAB_SECRET")
38
+ callback_url = os.getenv("SCHWAB_CALLBACK_URL")
39
+
40
+ if not api_key or not app_secret or not callback_url:
41
+ raise ValueError(
42
+ "Please set SCHWAB_API_KEY, SCHWAB_SECRET, and SCHWAB_CALLBACK_URL environment variables."
43
+ )
44
+ return api_key, app_secret, callback_url
45
+
46
+
47
+ def _resolve_method(method: SchwabClientMethod | str | None) -> SchwabClientMethod:
48
+ """Resolve auth method from explicit argument or environment variable."""
49
+ if isinstance(method, SchwabClientMethod):
50
+ return method
51
+
52
+ raw_method = method
53
+ if raw_method is None:
54
+ raw_method = os.getenv(
55
+ AUTH_METHOD_ENV_VAR, SchwabClientMethod.easy_client.value
56
+ )
57
+
58
+ try:
59
+ return SchwabClientMethod(raw_method)
60
+ except ValueError:
61
+ raise ValueError(f"Unknown method: {raw_method}")
62
+
63
+
64
+ def _resolve_token_path(token_path: str | Path | None) -> Path:
65
+ """Resolve token path from explicit argument, env var, or default."""
66
+ if token_path is None:
67
+ token_path = os.getenv(TOKEN_PATH_ENV_VAR)
68
+
69
+ if token_path is None:
70
+ token_path = DEFAULT_TOKEN_PATH
71
+
72
+ return Path(token_path).expanduser()
73
+
74
+
75
+ def _resolve_envchain_cmd(envchain_cmd: str | None) -> str:
76
+ """Resolve envchain binary path from explicit argument, env var, or default."""
77
+ if envchain_cmd:
78
+ return envchain_cmd
79
+
80
+ return os.getenv(ENVCHAIN_CMD_ENV_VAR, "/opt/homebrew/bin/envchain")
81
+
82
+
83
+ def _resolve_token_auth_credentials() -> tuple[str, str]:
84
+ """
85
+ Resolve API key + secret used by token-file clients for refresh operations.
86
+
87
+ Priority:
88
+ 1. SCHWAB_API_KEY + SCHWAB_SECRET env vars
89
+ 2. envchain namespace "schwab-app-data-studies"
90
+ 3. Placeholder values (works if token remains valid and no refresh is needed)
91
+ """
92
+ api_key = os.getenv("SCHWAB_API_KEY")
93
+ app_secret = os.getenv("SCHWAB_SECRET")
94
+ if api_key and app_secret:
95
+ return api_key, app_secret
96
+
97
+ try:
98
+ import envchain_python # pyright: ignore[reportMissingImports]
99
+
100
+ non_token_credentials = envchain_python.get_vars(
101
+ "schwab-app-data-studies", "SCHWAB_API_KEY", "SCHWAB_SECRET"
102
+ )
103
+
104
+ envchain_api_key = non_token_credentials.get("SCHWAB_API_KEY")
105
+ envchain_app_secret = non_token_credentials.get("SCHWAB_SECRET")
106
+ if envchain_api_key and envchain_app_secret:
107
+ return envchain_api_key, envchain_app_secret
108
+ except Exception:
109
+ pass
110
+
111
+ return (
112
+ api_key or _TOKEN_ONLY_PLACEHOLDER_API_KEY,
113
+ app_secret or _TOKEN_ONLY_PLACEHOLDER_SECRET,
114
+ )
115
+
116
+
117
+ @overload
118
+ def get_client(
119
+ asyncio_: Literal[True],
120
+ method: SchwabClientMethod | str | None = None,
121
+ token_path: str | Path | None = None,
122
+ envchain_cmd: str | None = None,
123
+ ) -> AsyncClient: ...
124
+
125
+
126
+ @overload
127
+ def get_client(
128
+ asyncio_: Literal[False] = False,
129
+ method: SchwabClientMethod | str | None = None,
130
+ token_path: str | Path | None = None,
131
+ envchain_cmd: str | None = None,
132
+ ) -> Client: ...
133
+
134
+
135
+ @overload
136
+ def get_client(
137
+ asyncio_: bool = False,
138
+ method: SchwabClientMethod | str | None = None,
139
+ token_path: str | Path | None = None,
140
+ envchain_cmd: str | None = None,
141
+ ) -> Client | AsyncClient: ...
142
+
143
+
144
+ @cache
145
+ def get_client(
146
+ asyncio_: bool = False,
147
+ method: SchwabClientMethod | str | None = None,
148
+ token_path: str | Path | None = None,
149
+ envchain_cmd: str | None = None,
150
+ ) -> Client | AsyncClient:
151
+ """
152
+ Get a Schwab API client.
153
+
154
+ Args:
155
+ asyncio_: If True, return an async client
156
+ method: Authentication method to use. If not provided, resolves from
157
+ SCHWAB_PY_UTILS_AUTH_METHOD or defaults to "easy_client".
158
+ token_path: Path to token file (for easy_client method).
159
+ If omitted, resolves from SCHWAB_PY_UTILS_TOKEN_PATH or defaults to
160
+ ~/.credentials/schwab-token.json
161
+ envchain_cmd: Path to envchain command (for envchain methods)
162
+
163
+ Returns:
164
+ Schwab Client or AsyncClient
165
+
166
+ Note:
167
+ Results are cached. Call `get_client.cache_clear()` if credentials change.
168
+ """
169
+ method = _resolve_method(method)
170
+ envchain_cmd = _resolve_envchain_cmd(envchain_cmd)
171
+
172
+ if method == SchwabClientMethod.easy_client:
173
+ from schwab.auth import client_from_token_file, easy_client
174
+
175
+ token_path = _resolve_token_path(token_path)
176
+ token_path.parent.mkdir(parents=True, exist_ok=True)
177
+
178
+ # Token-first mode: if token file exists, we can create a fully usable
179
+ # client without requiring SCHWAB_* env vars.
180
+ if token_path.is_file():
181
+ api_key, app_secret = _resolve_token_auth_credentials()
182
+
183
+ return client_from_token_file(
184
+ token_path=str(token_path),
185
+ api_key=api_key,
186
+ app_secret=app_secret,
187
+ asyncio=asyncio_,
188
+ )
189
+
190
+ # Fall back to easy_client login flow when no token exists.
191
+ api_key, app_secret, callback_url = _get_env_credentials()
192
+
193
+ return easy_client(
194
+ api_key=api_key,
195
+ app_secret=app_secret,
196
+ callback_url=callback_url,
197
+ token_path=str(token_path),
198
+ asyncio=asyncio_,
199
+ )
200
+
201
+ elif method == SchwabClientMethod.envchain_token:
202
+ from schwab.auth import client_from_access_functions
203
+
204
+ from .auth import create_envchain_token_handlers
205
+
206
+ api_key, app_secret, _ = _get_env_credentials()
207
+ token_read_func, token_write_func = create_envchain_token_handlers(
208
+ envchain_cmd=envchain_cmd
209
+ )
210
+
211
+ return client_from_access_functions(
212
+ api_key=api_key,
213
+ app_secret=app_secret,
214
+ token_read_func=token_read_func,
215
+ token_write_func=token_write_func,
216
+ asyncio=asyncio_,
217
+ )
218
+
219
+ elif method == SchwabClientMethod.envchain:
220
+ from schwab.auth import client_from_access_functions
221
+
222
+ try:
223
+ import envchain_python # pyright: ignore[reportMissingImports]
224
+ except ImportError:
225
+ raise ImportError(
226
+ "envchain-python is required for envchain method. "
227
+ "Install with: pip install schwab-py-utils[envchain]"
228
+ )
229
+
230
+ from .auth import create_envchain_token_handlers
231
+
232
+ non_token_credentials = envchain_python.get_vars(
233
+ "schwab-app-data-studies", "SCHWAB_API_KEY", "SCHWAB_SECRET"
234
+ )
235
+ api_key = non_token_credentials["SCHWAB_API_KEY"]
236
+ app_secret = non_token_credentials["SCHWAB_SECRET"]
237
+
238
+ token_read_func, token_write_func = create_envchain_token_handlers(
239
+ envchain_cmd=envchain_cmd
240
+ )
241
+
242
+ return client_from_access_functions(
243
+ api_key=api_key,
244
+ app_secret=app_secret,
245
+ token_read_func=token_read_func,
246
+ token_write_func=token_write_func,
247
+ asyncio=asyncio_,
248
+ )
249
+
250
+ else:
251
+ raise ValueError(f"Unknown method: {method}")
252
+
253
+
254
+ # Alias for backwards compatibility
255
+ get_schwab_client = get_client
256
+ SchwabPyGetClientMethod = SchwabClientMethod
@@ -0,0 +1,158 @@
1
+ from datetime import datetime
2
+ from warnings import warn
3
+
4
+ import httpx
5
+ import pandas as pd
6
+ from schwab.client import AsyncClient, Client
7
+
8
+ from ._types import SchwabPriceHistoryInterval, SchwabPriceHistoryIntervalStr
9
+ from .ticker import normalize_ticker
10
+
11
+
12
+ def _warn_if_futures(symbol: str) -> None:
13
+ """Warn if the symbol appears to be a futures contract."""
14
+ if symbol.startswith("/"):
15
+ warn(
16
+ f"Symbol '{symbol}' appears to be a futures contract. "
17
+ "Schwab's price history API only supports equities and ETFs, not futures. "
18
+ "You may receive limited or no data. For futures data, consider using "
19
+ "Schwab's streaming API (chart_futures_subs) or an alternative data source.",
20
+ UserWarning,
21
+ stacklevel=3,
22
+ )
23
+
24
+
25
+ # Mapping from interval to schwab-py method name
26
+ _INTERVAL_TO_METHOD: dict[
27
+ SchwabPriceHistoryInterval | SchwabPriceHistoryIntervalStr, str
28
+ ] = {
29
+ "1": "get_price_history_every_minute",
30
+ "5": "get_price_history_every_five_minutes",
31
+ "10": "get_price_history_every_ten_minutes",
32
+ "15": "get_price_history_every_fifteen_minutes",
33
+ "30": "get_price_history_every_thirty_minutes",
34
+ 1: "get_price_history_every_minute",
35
+ 5: "get_price_history_every_five_minutes",
36
+ 10: "get_price_history_every_ten_minutes",
37
+ 15: "get_price_history_every_fifteen_minutes",
38
+ 30: "get_price_history_every_thirty_minutes",
39
+ "d": "get_price_history_every_day",
40
+ "w": "get_price_history_every_week",
41
+ }
42
+
43
+
44
+ def price_history_to_df(price_history: dict | httpx.Response) -> pd.DataFrame:
45
+ """
46
+ Convert Schwab price history response to a pandas DataFrame.
47
+
48
+ Args:
49
+ price_history: Raw response from Schwab API (dict or httpx.Response)
50
+
51
+ Returns:
52
+ DataFrame with columns: open, high, low, close, volume, timestamp, datetime, date
53
+ """
54
+ if isinstance(price_history, httpx.Response):
55
+ price_history = price_history.json()
56
+
57
+ if price_history.get("empty", True):
58
+ return pd.DataFrame()
59
+
60
+ candles = price_history.get("candles")
61
+ if not candles:
62
+ return pd.DataFrame()
63
+
64
+ df = pd.DataFrame(candles)
65
+ df.rename(columns={"datetime": "timestamp"}, inplace=True)
66
+ df["datetime"] = pd.to_datetime(df["timestamp"], unit="ms", utc=True)
67
+ df["datetime"] = df["datetime"].dt.tz_convert("America/New_York")
68
+ df["date"] = df["datetime"].dt.date.astype(str)
69
+
70
+ return df
71
+
72
+
73
+ def get_price_history_raw(
74
+ client: Client | AsyncClient,
75
+ symbol: str,
76
+ interval: SchwabPriceHistoryInterval | SchwabPriceHistoryIntervalStr,
77
+ start_datetime: datetime | None = None,
78
+ end_datetime: datetime | None = None,
79
+ need_extended_hours_data: bool | None = None,
80
+ need_previous_close: bool | None = None,
81
+ ) -> dict:
82
+ """
83
+ Get price history from Schwab as raw dict.
84
+
85
+ Args:
86
+ client: Schwab API client
87
+ symbol: Ticker symbol
88
+ interval: Time interval (1, 5, 10, 15, 30 minutes, or "d", "w" for daily/weekly)
89
+ start_datetime: Start of date range
90
+ end_datetime: End of date range
91
+ need_extended_hours_data: Include extended hours data (default True in API)
92
+ need_previous_close: Include previous close price
93
+
94
+ Returns:
95
+ Raw dict from Schwab API
96
+ """
97
+ _warn_if_futures(symbol) # Check before normalization strips the /
98
+ symbol = normalize_ticker(symbol)
99
+ method_name = _INTERVAL_TO_METHOD[interval]
100
+
101
+ response = getattr(client, method_name)(
102
+ symbol,
103
+ start_datetime=start_datetime,
104
+ end_datetime=end_datetime,
105
+ need_extended_hours_data=need_extended_hours_data,
106
+ need_previous_close=need_previous_close,
107
+ )
108
+
109
+ return response.json()
110
+
111
+
112
+ def get_price_history(
113
+ client: Client | AsyncClient,
114
+ symbol: str,
115
+ interval: SchwabPriceHistoryInterval | SchwabPriceHistoryIntervalStr,
116
+ start_datetime: datetime | None = None,
117
+ end_datetime: datetime | None = None,
118
+ need_extended_hours_data: bool | None = None,
119
+ need_previous_close: bool | None = None,
120
+ set_datetime_index: bool = True,
121
+ ) -> pd.DataFrame:
122
+ """
123
+ Get price history from Schwab as a pandas DataFrame.
124
+
125
+ Args:
126
+ client: Schwab API client
127
+ symbol: Ticker symbol
128
+ interval: Time interval (1, 5, 10, 15, 30 minutes, or "d", "w" for daily/weekly)
129
+ start_datetime: Start of date range
130
+ end_datetime: End of date range
131
+ need_extended_hours_data: Include extended hours data (default True in API)
132
+ need_previous_close: Include previous close price
133
+ set_datetime_index: If True, set datetime as the index
134
+
135
+ Returns:
136
+ DataFrame with OHLCV data
137
+ """
138
+ raw = get_price_history_raw(
139
+ client,
140
+ symbol,
141
+ interval,
142
+ start_datetime=start_datetime,
143
+ end_datetime=end_datetime,
144
+ need_extended_hours_data=need_extended_hours_data,
145
+ need_previous_close=need_previous_close,
146
+ )
147
+
148
+ df = price_history_to_df(raw)
149
+
150
+ if set_datetime_index and "datetime" in df.columns:
151
+ df = df.set_index("datetime")
152
+
153
+ return df
154
+
155
+
156
+ # Alias for backwards compatibility
157
+ get_price_history_from_schwab = get_price_history
158
+ get_price_history_from_schwab_non_df = get_price_history_raw
@@ -0,0 +1,83 @@
1
+ from ._types import EquityTickerClassStyle, TickerNormalized, TickerUpperCase
2
+
3
+ # Common index tickers that should be prefixed with $
4
+ DEFAULT_DOLLAR_PREFIX_TICKERS: set[str] = {
5
+ "SPX",
6
+ "VIX",
7
+ "NDX",
8
+ "RUT",
9
+ "DJI",
10
+ "DJX",
11
+ "VVIX",
12
+ "VIX9D",
13
+ "VIX3M",
14
+ "VIX6M",
15
+ "VIX1Y",
16
+ "VIXEQ",
17
+ "VIX1D",
18
+ "VXN",
19
+ "RVX",
20
+ "VXD",
21
+ }
22
+
23
+ # Module-level set that users can extend
24
+ dollar_prefix_tickers: set[str] = DEFAULT_DOLLAR_PREFIX_TICKERS.copy()
25
+
26
+
27
+ def add_dollar_prefix_tickers(*tickers: str) -> None:
28
+ """Add tickers to the set that should be prefixed with $."""
29
+ for t in tickers:
30
+ dollar_prefix_tickers.add(t.upper().lstrip("$"))
31
+
32
+
33
+ def normalize_ticker(
34
+ ticker: str,
35
+ prefix_dollar: bool = True,
36
+ normalize_index_tickers: bool = True,
37
+ equity_ticker_class_style: EquityTickerClassStyle = "/",
38
+ prefix_futures_ticker: bool = False,
39
+ ) -> TickerNormalized | TickerUpperCase:
40
+ """
41
+ Normalize a ticker symbol.
42
+
43
+ - Uppercase the ticker
44
+ - Optionally add $ prefix for index tickers
45
+ - Normalize index ticker variants (SPXW -> SPX, etc.)
46
+ - Handle equity ticker class styles (BRK.B vs BRK/B)
47
+
48
+ Args:
49
+ ticker: The ticker symbol to normalize
50
+ prefix_dollar: If True, add $ prefix for index tickers
51
+ normalize_index_tickers: If True, normalize variants like SPXW -> SPX
52
+ equity_ticker_class_style: "/" or "." for class shares (e.g., BRK/B or BRK.B)
53
+ prefix_futures_ticker: If True, prefix with / for futures
54
+
55
+ Returns:
56
+ Normalized ticker string
57
+ """
58
+ ticker = ticker.upper().lstrip("/$")
59
+
60
+ if equity_ticker_class_style == "/":
61
+ ticker = ticker.replace(".", "/")
62
+ elif equity_ticker_class_style == ".":
63
+ ticker = ticker.replace("/", ".")
64
+
65
+ if normalize_index_tickers:
66
+ if ticker == "SPXW":
67
+ ticker = "SPX"
68
+ elif ticker == "NDXP":
69
+ ticker = "NDX"
70
+ elif ticker == "RUTW":
71
+ ticker = "RUT"
72
+ elif ticker == "VIXW":
73
+ ticker = "VIX"
74
+
75
+ if prefix_dollar:
76
+ if ticker in dollar_prefix_tickers:
77
+ return f"${ticker}"
78
+ else:
79
+ return ticker
80
+ elif prefix_futures_ticker:
81
+ return f"/{ticker.strip('$/')}"
82
+ else:
83
+ return ticker.removeprefix("$")