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.
- schwab_py_utils-0.1.0/PKG-INFO +9 -0
- schwab_py_utils-0.1.0/pyproject.toml +32 -0
- schwab_py_utils-0.1.0/src/schwab_py_utils/__init__.py +67 -0
- schwab_py_utils-0.1.0/src/schwab_py_utils/_types.py +9 -0
- schwab_py_utils-0.1.0/src/schwab_py_utils/auth.py +114 -0
- schwab_py_utils-0.1.0/src/schwab_py_utils/client.py +256 -0
- schwab_py_utils-0.1.0/src/schwab_py_utils/price_history.py +158 -0
- schwab_py_utils-0.1.0/src/schwab_py_utils/ticker.py +83 -0
|
@@ -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("$")
|