ml4t-data 0.1.0__py3-none-any.whl
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.
- ml4t/data/AGENTS.md +46 -0
- ml4t/data/__init__.py +54 -0
- ml4t/data/_version.py +24 -0
- ml4t/data/adjustments/__init__.py +23 -0
- ml4t/data/adjustments/core.py +252 -0
- ml4t/data/anomaly/AGENTS.md +16 -0
- ml4t/data/anomaly/__init__.py +23 -0
- ml4t/data/anomaly/base.py +142 -0
- ml4t/data/anomaly/config.py +117 -0
- ml4t/data/anomaly/detectors.py +496 -0
- ml4t/data/anomaly/manager.py +306 -0
- ml4t/data/assets/AGENTS.md +16 -0
- ml4t/data/assets/__init__.py +26 -0
- ml4t/data/assets/asset_class.py +252 -0
- ml4t/data/assets/contracts.py +422 -0
- ml4t/data/assets/data/__init__.py +0 -0
- ml4t/data/assets/data/crypto_top100.json +102 -0
- ml4t/data/assets/data/forex_majors.json +30 -0
- ml4t/data/assets/data/nasdaq100.json +102 -0
- ml4t/data/assets/data/sp500.json +505 -0
- ml4t/data/assets/schemas.py +322 -0
- ml4t/data/assets/validation.py +296 -0
- ml4t/data/calendar/__init__.py +0 -0
- ml4t/data/calendar/crypto.py +366 -0
- ml4t/data/cli/__init__.py +69 -0
- ml4t/data/cli/batch.py +206 -0
- ml4t/data/cli/config.py +83 -0
- ml4t/data/cli/core.py +805 -0
- ml4t/data/cli/cot.py +278 -0
- ml4t/data/cli/futures.py +350 -0
- ml4t/data/cli/utils.py +169 -0
- ml4t/data/cli_interface.py +59 -0
- ml4t/data/config/__init__.py +38 -0
- ml4t/data/config/_serialization.py +46 -0
- ml4t/data/config/loader.py +331 -0
- ml4t/data/config/models.py +607 -0
- ml4t/data/config/validator.py +247 -0
- ml4t/data/core/AGENTS.md +16 -0
- ml4t/data/core/__init__.py +6 -0
- ml4t/data/core/config.py +123 -0
- ml4t/data/core/exceptions.py +315 -0
- ml4t/data/core/models.py +179 -0
- ml4t/data/core/schemas.py +467 -0
- ml4t/data/cot/AGENTS.md +14 -0
- ml4t/data/cot/__init__.py +46 -0
- ml4t/data/cot/fetcher.py +695 -0
- ml4t/data/cot/workflow.py +519 -0
- ml4t/data/crypto/__init__.py +15 -0
- ml4t/data/crypto/downloader.py +500 -0
- ml4t/data/data_manager.py +584 -0
- ml4t/data/etfs/__init__.py +16 -0
- ml4t/data/etfs/downloader.py +589 -0
- ml4t/data/export/__init__.py +5 -0
- ml4t/data/export/formats/__init__.py +14 -0
- ml4t/data/export/formats/base.py +145 -0
- ml4t/data/export/formats/csv.py +138 -0
- ml4t/data/export/formats/excel.py +347 -0
- ml4t/data/export/formats/json.py +280 -0
- ml4t/data/export/manager.py +364 -0
- ml4t/data/futures/AGENTS.md +116 -0
- ml4t/data/futures/__init__.py +188 -0
- ml4t/data/futures/adjustment.py +115 -0
- ml4t/data/futures/book_downloader.py +760 -0
- ml4t/data/futures/config.py +230 -0
- ml4t/data/futures/continuous.py +227 -0
- ml4t/data/futures/continuous_downloader.py +568 -0
- ml4t/data/futures/databento_parser.py +607 -0
- ml4t/data/futures/definitions.py +334 -0
- ml4t/data/futures/downloader.py +857 -0
- ml4t/data/futures/individual_downloader.py +501 -0
- ml4t/data/futures/parser.py +328 -0
- ml4t/data/futures/roll.py +415 -0
- ml4t/data/futures/schema.py +433 -0
- ml4t/data/macro/__init__.py +15 -0
- ml4t/data/macro/downloader.py +465 -0
- ml4t/data/managers/__init__.py +46 -0
- ml4t/data/managers/async_batch.py +281 -0
- ml4t/data/managers/batch_manager.py +409 -0
- ml4t/data/managers/bulk_manager.py +326 -0
- ml4t/data/managers/config_manager.py +262 -0
- ml4t/data/managers/fetch_manager.py +279 -0
- ml4t/data/managers/metadata_manager.py +296 -0
- ml4t/data/managers/provider_manager.py +378 -0
- ml4t/data/managers/storage_manager.py +631 -0
- ml4t/data/provider_updater.py +466 -0
- ml4t/data/providers/AGENTS.md +63 -0
- ml4t/data/providers/__init__.py +216 -0
- ml4t/data/providers/alpaca.py +1039 -0
- ml4t/data/providers/aqr.py +1185 -0
- ml4t/data/providers/async_base.py +264 -0
- ml4t/data/providers/base.py +360 -0
- ml4t/data/providers/binance.py +415 -0
- ml4t/data/providers/binance_public.py +1923 -0
- ml4t/data/providers/coingecko.py +766 -0
- ml4t/data/providers/cryptocompare.py +576 -0
- ml4t/data/providers/databento.py +727 -0
- ml4t/data/providers/eodhd.py +624 -0
- ml4t/data/providers/fama_french.py +1003 -0
- ml4t/data/providers/finnhub.py +444 -0
- ml4t/data/providers/fred.py +586 -0
- ml4t/data/providers/fundamentals.py +406 -0
- ml4t/data/providers/fxmacrodata.py +749 -0
- ml4t/data/providers/kalshi.py +801 -0
- ml4t/data/providers/learned_synthetic.py +529 -0
- ml4t/data/providers/mixins/__init__.py +45 -0
- ml4t/data/providers/mixins/async_session.py +143 -0
- ml4t/data/providers/mixins/circuit_breaker.py +362 -0
- ml4t/data/providers/mixins/rate_limit.py +91 -0
- ml4t/data/providers/mixins/retry.py +116 -0
- ml4t/data/providers/mixins/session.py +127 -0
- ml4t/data/providers/mixins/validation.py +366 -0
- ml4t/data/providers/mock.py +355 -0
- ml4t/data/providers/nasdaq_itch.py +638 -0
- ml4t/data/providers/oanda.py +328 -0
- ml4t/data/providers/okx.py +602 -0
- ml4t/data/providers/polygon.py +574 -0
- ml4t/data/providers/polymarket.py +976 -0
- ml4t/data/providers/protocols.py +249 -0
- ml4t/data/providers/registry.py +348 -0
- ml4t/data/providers/synthetic.py +543 -0
- ml4t/data/providers/tiingo.py +238 -0
- ml4t/data/providers/twelve_data.py +266 -0
- ml4t/data/providers/wiki_prices.py +807 -0
- ml4t/data/providers/yahoo.py +658 -0
- ml4t/data/py.typed +0 -0
- ml4t/data/security/__init__.py +5 -0
- ml4t/data/security/path_validator.py +255 -0
- ml4t/data/sessions/__init__.py +10 -0
- ml4t/data/sessions/assigner.py +287 -0
- ml4t/data/sessions/completer.py +359 -0
- ml4t/data/storage/AGENTS.md +45 -0
- ml4t/data/storage/__init__.py +92 -0
- ml4t/data/storage/async_base.py +102 -0
- ml4t/data/storage/backend.py +448 -0
- ml4t/data/storage/chunked.py +703 -0
- ml4t/data/storage/config.py +162 -0
- ml4t/data/storage/data_profile.py +325 -0
- ml4t/data/storage/flat.py +188 -0
- ml4t/data/storage/hive.py +640 -0
- ml4t/data/storage/keys.py +111 -0
- ml4t/data/storage/legacy_migration.py +454 -0
- ml4t/data/storage/metadata_tracker.py +399 -0
- ml4t/data/storage/migration.py +598 -0
- ml4t/data/storage/protocols.py +129 -0
- ml4t/data/synthetic/__init__.py +51 -0
- ml4t/data/synthetic/ohlcv_utils.py +404 -0
- ml4t/data/synthetic/registry.py +409 -0
- ml4t/data/universe.py +217 -0
- ml4t/data/update_manager.py +827 -0
- ml4t/data/utils/AGENTS.md +20 -0
- ml4t/data/utils/__init__.py +8 -0
- ml4t/data/utils/async_rate_limit.py +302 -0
- ml4t/data/utils/conversion.py +138 -0
- ml4t/data/utils/format.py +342 -0
- ml4t/data/utils/gap_optimizer.py +148 -0
- ml4t/data/utils/gaps.py +329 -0
- ml4t/data/utils/global_rate_limit.py +118 -0
- ml4t/data/utils/locking.py +193 -0
- ml4t/data/utils/rate_limit.py +87 -0
- ml4t/data/utils/retry.py +54 -0
- ml4t/data/validation/__init__.py +13 -0
- ml4t/data/validation/base.py +86 -0
- ml4t/data/validation/cross_validation.py +286 -0
- ml4t/data/validation/ohlcv.py +409 -0
- ml4t/data/validation/report.py +223 -0
- ml4t/data/validation/rules.py +300 -0
- ml4t_data-0.1.0.dist-info/METADATA +457 -0
- ml4t_data-0.1.0.dist-info/RECORD +171 -0
- ml4t_data-0.1.0.dist-info/WHEEL +4 -0
- ml4t_data-0.1.0.dist-info/entry_points.txt +2 -0
- ml4t_data-0.1.0.dist-info/licenses/LICENSE +21 -0
ml4t/data/AGENTS.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# ml4t.data - Package Index
|
|
2
|
+
|
|
3
|
+
## Core Modules
|
|
4
|
+
|
|
5
|
+
| File | Lines | Purpose |
|
|
6
|
+
|------|-------|---------|
|
|
7
|
+
| data_manager.py | 564 | Main orchestration API |
|
|
8
|
+
| update_manager.py | 829 | Incremental update system |
|
|
9
|
+
| universe.py | 987 | Asset universe management |
|
|
10
|
+
| provider_updater.py | 449 | Provider coordination |
|
|
11
|
+
|
|
12
|
+
## Subpackages
|
|
13
|
+
|
|
14
|
+
| Directory | Lines | Purpose |
|
|
15
|
+
|-----------|-------|---------|
|
|
16
|
+
| providers/ | 14k | 20 live provider adapters + synthetic/testing providers |
|
|
17
|
+
| storage/ | 4.6k | Hive-partitioned backends + profiling |
|
|
18
|
+
| futures/ | 4.6k | Databento futures downloader |
|
|
19
|
+
| etfs/ | 600 | ETFDataManager (Yahoo Finance) |
|
|
20
|
+
| crypto/ | 420 | CryptoDataManager (Binance Public) |
|
|
21
|
+
| core/ | 1k | Models, schemas, config |
|
|
22
|
+
| utils/ | 1.6k | Rate limiting, gaps, retry |
|
|
23
|
+
| assets/ | 1.3k | Asset class definitions |
|
|
24
|
+
| anomaly/ | 1k | Data quality detection |
|
|
25
|
+
| cot/ | 1k | CFTC COT data |
|
|
26
|
+
| validation/ | 1.2k | OHLC validation |
|
|
27
|
+
| sessions/ | 462 | Session assignment |
|
|
28
|
+
| macro/ | 455 | Macro data downloader |
|
|
29
|
+
| calendar/ | 359 | Trading calendars |
|
|
30
|
+
| export/ | 231 | CSV/JSON/Excel export |
|
|
31
|
+
|
|
32
|
+
## Book Data Managers
|
|
33
|
+
|
|
34
|
+
Simplified managers for ML4T book readers with built-in profiling:
|
|
35
|
+
|
|
36
|
+
| Manager | Asset | Source | Profiling |
|
|
37
|
+
|---------|-------|--------|-----------|
|
|
38
|
+
| `ETFDataManager` | ETFs | Yahoo Finance | `generate_profile()` |
|
|
39
|
+
| `CryptoDataManager` | Crypto | Binance Public | `generate_profile()` |
|
|
40
|
+
| `FuturesDataManager` | Futures | Databento | `generate_profile(product)` |
|
|
41
|
+
|
|
42
|
+
All managers inherit from `ProfileMixin` for on-demand column statistics.
|
|
43
|
+
|
|
44
|
+
## Key
|
|
45
|
+
|
|
46
|
+
`DataManager`, `UpdateManager`, `HiveStorage`, `ProfileMixin`, `get_provider()`
|
ml4t/data/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""ML4T Data - Modern financial data management library with unified provider interface."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from ml4t.data._version import __version__
|
|
5
|
+
except ImportError:
|
|
6
|
+
__version__ = "0.0.0.dev0"
|
|
7
|
+
__author__ = "ML4T Team"
|
|
8
|
+
__email__ = "info@ml4trading.io"
|
|
9
|
+
|
|
10
|
+
# Contract specifications (always available - no external dependencies)
|
|
11
|
+
# Asset classes
|
|
12
|
+
from ml4t.data.assets.asset_class import AssetClass
|
|
13
|
+
from ml4t.data.assets.contracts import (
|
|
14
|
+
FUTURES_REGISTRY,
|
|
15
|
+
ContractSpec,
|
|
16
|
+
get_contract_spec,
|
|
17
|
+
load_contract_specs,
|
|
18
|
+
register_contract_spec,
|
|
19
|
+
)
|
|
20
|
+
from ml4t.data.core.exceptions import ProviderRoutingError
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
# Contract specifications
|
|
24
|
+
"ContractSpec",
|
|
25
|
+
"FUTURES_REGISTRY",
|
|
26
|
+
"get_contract_spec",
|
|
27
|
+
"load_contract_specs",
|
|
28
|
+
"register_contract_spec",
|
|
29
|
+
"AssetClass",
|
|
30
|
+
"ProviderRoutingError",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# Core imports (may have additional dependencies)
|
|
34
|
+
try:
|
|
35
|
+
from ml4t.data.core.config import Config # noqa: F401
|
|
36
|
+
from ml4t.data.data_manager import DataManager # noqa: F401
|
|
37
|
+
from ml4t.data.providers.base import BaseProvider # noqa: F401
|
|
38
|
+
|
|
39
|
+
__all__.extend(["BaseProvider", "Config", "DataManager"])
|
|
40
|
+
except ImportError:
|
|
41
|
+
# Try to import what's available
|
|
42
|
+
try:
|
|
43
|
+
from ml4t.data.data_manager import DataManager # noqa: F401
|
|
44
|
+
|
|
45
|
+
__all__.append("DataManager")
|
|
46
|
+
except ImportError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from ml4t.data.providers.base import BaseProvider # noqa: F401
|
|
51
|
+
|
|
52
|
+
__all__.append("BaseProvider")
|
|
53
|
+
except ImportError:
|
|
54
|
+
pass
|
ml4t/data/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Corporate actions and price adjustment utilities.
|
|
2
|
+
|
|
3
|
+
The canonical convention treats a split ratio as new shares per old share on
|
|
4
|
+
the event date. Raw values are retained, and adjusted values use the share
|
|
5
|
+
basis of the latest observation.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from ml4t.data.adjustments import apply_corporate_actions
|
|
9
|
+
>>> adjusted_prices = apply_corporate_actions(unadjusted_df)
|
|
10
|
+
>>> adjusted_prices.select("close", "adj_close", "price_adjustment_factor")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .core import (
|
|
14
|
+
apply_corporate_actions,
|
|
15
|
+
apply_dividends,
|
|
16
|
+
apply_splits,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"apply_splits",
|
|
21
|
+
"apply_dividends",
|
|
22
|
+
"apply_corporate_actions",
|
|
23
|
+
]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Corporate-action adjustment functions."""
|
|
2
|
+
|
|
3
|
+
import polars as pl
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _validate_adjustment_inputs(
|
|
7
|
+
df: pl.DataFrame,
|
|
8
|
+
*,
|
|
9
|
+
split_col: str,
|
|
10
|
+
dividend_col: str | None,
|
|
11
|
+
price_cols: list[str],
|
|
12
|
+
volume_col: str | None,
|
|
13
|
+
) -> None:
|
|
14
|
+
required = {"date", split_col, *price_cols}
|
|
15
|
+
if dividend_col is not None:
|
|
16
|
+
required.update(("close", dividend_col))
|
|
17
|
+
missing = sorted(required.difference(df.columns))
|
|
18
|
+
if missing:
|
|
19
|
+
raise ValueError(f"Missing required adjustment columns: {', '.join(missing)}")
|
|
20
|
+
if df.get_column("date").null_count():
|
|
21
|
+
raise ValueError("Corporate-action dates cannot contain null values")
|
|
22
|
+
if df.get_column("date").n_unique() != df.height:
|
|
23
|
+
raise ValueError("Corporate-action input must contain exactly one row per date")
|
|
24
|
+
|
|
25
|
+
finite_columns = [split_col, *price_cols]
|
|
26
|
+
if dividend_col is not None:
|
|
27
|
+
finite_columns.extend(("close", dividend_col))
|
|
28
|
+
if volume_col is not None and volume_col in df.columns:
|
|
29
|
+
finite_columns.append(volume_col)
|
|
30
|
+
for column in dict.fromkeys(finite_columns):
|
|
31
|
+
invalid = df.filter(pl.col(column).is_null() | ~pl.col(column).is_finite())
|
|
32
|
+
if not invalid.is_empty():
|
|
33
|
+
raise ValueError(f"Adjustment column '{column}' must contain finite values")
|
|
34
|
+
|
|
35
|
+
if not df.filter(pl.col(split_col) <= 0).is_empty():
|
|
36
|
+
raise ValueError(f"Split ratios in '{split_col}' must be positive")
|
|
37
|
+
if dividend_col is not None and not df.filter(pl.col("close") <= 0).is_empty():
|
|
38
|
+
raise ValueError("Close prices must be positive")
|
|
39
|
+
if dividend_col is not None and not df.filter(pl.col(dividend_col) < 0).is_empty():
|
|
40
|
+
raise ValueError(f"Dividends in '{dividend_col}' cannot be negative")
|
|
41
|
+
if volume_col is not None and volume_col in df.columns:
|
|
42
|
+
if not df.filter(pl.col(volume_col) < 0).is_empty():
|
|
43
|
+
raise ValueError(f"Volume in '{volume_col}' cannot be negative")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _future_cumulative_product(column: str) -> pl.Expr:
|
|
47
|
+
return pl.col(column).shift(-1, fill_value=1.0).reverse().cum_prod().reverse()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply_canonical_adjustments(
|
|
51
|
+
prices: pl.DataFrame,
|
|
52
|
+
*,
|
|
53
|
+
split_col: str,
|
|
54
|
+
dividend_col: str | None,
|
|
55
|
+
price_cols: list[str],
|
|
56
|
+
volume_col: str | None,
|
|
57
|
+
) -> pl.DataFrame:
|
|
58
|
+
_validate_adjustment_inputs(
|
|
59
|
+
prices,
|
|
60
|
+
split_col=split_col,
|
|
61
|
+
dividend_col=dividend_col,
|
|
62
|
+
price_cols=price_cols,
|
|
63
|
+
volume_col=volume_col,
|
|
64
|
+
)
|
|
65
|
+
df = prices.sort("date").clone()
|
|
66
|
+
|
|
67
|
+
price_event_factor = (
|
|
68
|
+
pl.col("close")
|
|
69
|
+
/ (pl.col(split_col).cast(pl.Float64) * (pl.col("close") + pl.col(dividend_col)))
|
|
70
|
+
if dividend_col is not None
|
|
71
|
+
else 1.0 / pl.col(split_col).cast(pl.Float64)
|
|
72
|
+
)
|
|
73
|
+
df = df.with_columns(
|
|
74
|
+
price_event_factor.alias("_price_event_factor"),
|
|
75
|
+
pl.col(split_col).cast(pl.Float64).alias("_volume_event_factor"),
|
|
76
|
+
).with_columns(
|
|
77
|
+
_future_cumulative_product("_price_event_factor").alias("price_adjustment_factor"),
|
|
78
|
+
_future_cumulative_product("_volume_event_factor").alias("volume_adjustment_factor"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
adjustments = [
|
|
82
|
+
(pl.col(column) * pl.col("price_adjustment_factor")).alias(f"adj_{column}")
|
|
83
|
+
for column in price_cols
|
|
84
|
+
]
|
|
85
|
+
if volume_col is not None and volume_col in df.columns:
|
|
86
|
+
adjustments.append(
|
|
87
|
+
(pl.col(volume_col) * pl.col("volume_adjustment_factor")).alias(f"adj_{volume_col}")
|
|
88
|
+
)
|
|
89
|
+
return df.with_columns(adjustments).drop("_price_event_factor", "_volume_event_factor")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def apply_corporate_actions(
|
|
93
|
+
prices: pl.DataFrame,
|
|
94
|
+
split_col: str = "split_ratio",
|
|
95
|
+
dividend_col: str = "ex-dividend",
|
|
96
|
+
price_cols: list[str] | None = None,
|
|
97
|
+
volume_col: str | None = "volume",
|
|
98
|
+
) -> pl.DataFrame:
|
|
99
|
+
"""Adjust prices and volume to the share basis of the latest observation.
|
|
100
|
+
|
|
101
|
+
A split ratio on date ``t`` is new shares per old share and applies between
|
|
102
|
+
the preceding observation and ``t``. The row on ``t`` is already on the new
|
|
103
|
+
share basis. Earlier prices are divided by subsequent split ratios and
|
|
104
|
+
earlier volume is multiplied by them. A dividend is cash per post-event
|
|
105
|
+
share on its ex-date. Adjusted close-to-close returns include that cash.
|
|
106
|
+
The dividend factor uses the ex-date close. This differs from data vendors
|
|
107
|
+
that discount dividends using the prior close.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
prices: DataFrame with date-sorted prices and corporate action data
|
|
111
|
+
split_col: Column with split ratios (default: 'split_ratio')
|
|
112
|
+
dividend_col: Column with dividend amounts (default: 'ex-dividend')
|
|
113
|
+
price_cols: Price columns to adjust (default: ['open', 'high', 'low', 'close'])
|
|
114
|
+
volume_col: Volume column to adjust (default: 'volume')
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
DataFrame retaining raw columns and adding adjusted columns plus explicit
|
|
118
|
+
price and volume adjustment factors
|
|
119
|
+
"""
|
|
120
|
+
if price_cols is None:
|
|
121
|
+
price_cols = [
|
|
122
|
+
column for column in ("open", "high", "low", "close") if column in prices.columns
|
|
123
|
+
]
|
|
124
|
+
return _apply_canonical_adjustments(
|
|
125
|
+
prices,
|
|
126
|
+
split_col=split_col,
|
|
127
|
+
dividend_col=dividend_col,
|
|
128
|
+
price_cols=price_cols,
|
|
129
|
+
volume_col=volume_col,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def apply_splits(
|
|
134
|
+
prices: pl.DataFrame,
|
|
135
|
+
split_col: str = "split_ratio",
|
|
136
|
+
price_cols: list[str] | None = None,
|
|
137
|
+
volume_col: str | None = "volume",
|
|
138
|
+
) -> pl.DataFrame:
|
|
139
|
+
"""Apply the canonical split-only adjustment convention.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
prices: DataFrame with date-sorted prices and split_ratio column
|
|
143
|
+
split_col: Name of column containing split ratios (default: 'split_ratio')
|
|
144
|
+
price_cols: List of price columns to adjust (default: ['open', 'high', 'low', 'close'])
|
|
145
|
+
volume_col: Volume column name to adjust
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
DataFrame retaining raw values and adding adjusted values and factors
|
|
149
|
+
"""
|
|
150
|
+
if price_cols is None:
|
|
151
|
+
price_cols = [
|
|
152
|
+
column for column in ("open", "high", "low", "close") if column in prices.columns
|
|
153
|
+
]
|
|
154
|
+
return _apply_canonical_adjustments(
|
|
155
|
+
prices,
|
|
156
|
+
split_col=split_col,
|
|
157
|
+
dividend_col=None,
|
|
158
|
+
price_cols=price_cols,
|
|
159
|
+
volume_col=volume_col,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def apply_dividends(
|
|
164
|
+
prices: pl.DataFrame,
|
|
165
|
+
dividend_col: str = "ex-dividend",
|
|
166
|
+
price_cols: list[str] | None = None,
|
|
167
|
+
close_col: str = "adj_close",
|
|
168
|
+
) -> pl.DataFrame:
|
|
169
|
+
"""Apply dividend-only adjustments that preserve total close-to-close returns.
|
|
170
|
+
|
|
171
|
+
A dividend on date ``t`` is cash per share paid between the preceding
|
|
172
|
+
observation and ``t``. The event row remains unchanged. This adapter is for
|
|
173
|
+
data whose split adjustments have already been handled consistently. When
|
|
174
|
+
split factors are present, dividends are converted to the latest share basis
|
|
175
|
+
and the dividend factor is composed with the existing price factor.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
prices: DataFrame with date-sorted prices and ex-dividend column
|
|
179
|
+
dividend_col: Name of column containing dividend amounts
|
|
180
|
+
price_cols: List of price columns to adjust
|
|
181
|
+
close_col: Column to use for dividend factor calculation (default: 'adj_close')
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
DataFrame with adjusted price columns and an explicit adjustment factor
|
|
185
|
+
"""
|
|
186
|
+
if price_cols is None:
|
|
187
|
+
price_cols = [
|
|
188
|
+
column
|
|
189
|
+
for column in ("adj_open", "adj_high", "adj_low", "adj_close")
|
|
190
|
+
if column in prices.columns
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
required = {"date", dividend_col, close_col, *price_cols}
|
|
194
|
+
missing = sorted(required.difference(prices.columns))
|
|
195
|
+
if missing:
|
|
196
|
+
raise ValueError(f"Missing required dividend columns: {', '.join(missing)}")
|
|
197
|
+
df = prices.sort("date").clone()
|
|
198
|
+
if df.get_column("date").null_count():
|
|
199
|
+
raise ValueError("Dividend dates cannot contain null values")
|
|
200
|
+
if df.get_column("date").n_unique() != df.height:
|
|
201
|
+
raise ValueError("Dividend input must contain exactly one row per date")
|
|
202
|
+
for column in (dividend_col, close_col, *price_cols):
|
|
203
|
+
if not df.filter(pl.col(column).is_null() | ~pl.col(column).is_finite()).is_empty():
|
|
204
|
+
raise ValueError(f"Dividend adjustment column '{column}' must contain finite values")
|
|
205
|
+
if not df.filter(pl.col(dividend_col) < 0).is_empty():
|
|
206
|
+
raise ValueError(f"Dividends in '{dividend_col}' cannot be negative")
|
|
207
|
+
if not df.filter(pl.col(close_col) <= 0).is_empty():
|
|
208
|
+
raise ValueError(f"Close prices in '{close_col}' must be positive")
|
|
209
|
+
|
|
210
|
+
for factor_column in ("price_adjustment_factor", "volume_adjustment_factor"):
|
|
211
|
+
if (
|
|
212
|
+
factor_column in df.columns
|
|
213
|
+
and not df.filter(
|
|
214
|
+
pl.col(factor_column).is_null()
|
|
215
|
+
| ~pl.col(factor_column).is_finite()
|
|
216
|
+
| (pl.col(factor_column) <= 0)
|
|
217
|
+
).is_empty()
|
|
218
|
+
):
|
|
219
|
+
raise ValueError(f"Adjustment factor '{factor_column}' must be finite and positive")
|
|
220
|
+
|
|
221
|
+
rebased_dividend = pl.col(dividend_col)
|
|
222
|
+
if "volume_adjustment_factor" in df.columns:
|
|
223
|
+
rebased_dividend = rebased_dividend / pl.col("volume_adjustment_factor")
|
|
224
|
+
existing_factor = (
|
|
225
|
+
pl.col("price_adjustment_factor")
|
|
226
|
+
if "price_adjustment_factor" in df.columns
|
|
227
|
+
else pl.lit(1.0)
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return (
|
|
231
|
+
df.with_columns(rebased_dividend.alias("_rebased_dividend"))
|
|
232
|
+
.with_columns(
|
|
233
|
+
(pl.col(close_col) / (pl.col(close_col) + pl.col("_rebased_dividend"))).alias(
|
|
234
|
+
"_dividend_event_factor"
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
.with_columns(
|
|
238
|
+
_future_cumulative_product("_dividend_event_factor").alias(
|
|
239
|
+
"_dividend_adjustment_factor"
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
.with_columns(
|
|
243
|
+
*[
|
|
244
|
+
(pl.col(column) * pl.col("_dividend_adjustment_factor")).alias(column)
|
|
245
|
+
for column in price_cols
|
|
246
|
+
],
|
|
247
|
+
(existing_factor * pl.col("_dividend_adjustment_factor")).alias(
|
|
248
|
+
"price_adjustment_factor"
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
.drop("_rebased_dividend", "_dividend_event_factor", "_dividend_adjustment_factor")
|
|
252
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# anomaly/ - 1k Lines
|
|
2
|
+
|
|
3
|
+
Data quality and anomaly detection.
|
|
4
|
+
|
|
5
|
+
## Functions
|
|
6
|
+
|
|
7
|
+
| Function | Purpose |
|
|
8
|
+
|----------|---------|
|
|
9
|
+
| detect_anomalies | Find data anomalies |
|
|
10
|
+
| validate_ohlc | OHLC consistency |
|
|
11
|
+
| check_gaps | Missing data detection |
|
|
12
|
+
| flag_outliers | Statistical outliers |
|
|
13
|
+
|
|
14
|
+
## Key
|
|
15
|
+
|
|
16
|
+
`AnomalyDetector`, `detect_anomalies()`, `DataQualityReport`
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Anomaly detection for financial data."""
|
|
2
|
+
|
|
3
|
+
from ml4t.data.anomaly.base import Anomaly, AnomalyDetector, AnomalyReport, AnomalySeverity
|
|
4
|
+
from ml4t.data.anomaly.config import AnomalyConfig, DetectorConfig
|
|
5
|
+
from ml4t.data.anomaly.detectors import (
|
|
6
|
+
PriceStalenessDetector,
|
|
7
|
+
ReturnOutlierDetector,
|
|
8
|
+
VolumeSpikeDetector,
|
|
9
|
+
)
|
|
10
|
+
from ml4t.data.anomaly.manager import AnomalyManager
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Anomaly",
|
|
14
|
+
"AnomalyConfig",
|
|
15
|
+
"AnomalyDetector",
|
|
16
|
+
"AnomalyManager",
|
|
17
|
+
"AnomalyReport",
|
|
18
|
+
"AnomalySeverity",
|
|
19
|
+
"DetectorConfig",
|
|
20
|
+
"PriceStalenessDetector",
|
|
21
|
+
"ReturnOutlierDetector",
|
|
22
|
+
"VolumeSpikeDetector",
|
|
23
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Base classes for anomaly detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import polars as pl
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AnomalySeverity(StrEnum):
|
|
15
|
+
"""Anomaly severity levels."""
|
|
16
|
+
|
|
17
|
+
INFO = "info"
|
|
18
|
+
WARNING = "warning"
|
|
19
|
+
ERROR = "error"
|
|
20
|
+
CRITICAL = "critical"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnomalyType(StrEnum):
|
|
24
|
+
"""Types of anomalies."""
|
|
25
|
+
|
|
26
|
+
RETURN_OUTLIER = "return_outlier"
|
|
27
|
+
VOLUME_SPIKE = "volume_spike"
|
|
28
|
+
PRICE_STALE = "price_stale"
|
|
29
|
+
DATA_GAP = "data_gap"
|
|
30
|
+
PRICE_SPIKE = "price_spike"
|
|
31
|
+
ZERO_VOLUME = "zero_volume"
|
|
32
|
+
NEGATIVE_PRICE = "negative_price"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Anomaly(BaseModel):
|
|
36
|
+
"""Represents a detected anomaly."""
|
|
37
|
+
|
|
38
|
+
timestamp: datetime = Field(description="When the anomaly occurred")
|
|
39
|
+
symbol: str = Field(description="Symbol with the anomaly")
|
|
40
|
+
type: AnomalyType = Field(description="Type of anomaly")
|
|
41
|
+
severity: AnomalySeverity = Field(description="Severity level")
|
|
42
|
+
value: float = Field(description="The anomalous value")
|
|
43
|
+
expected_range: tuple[float, float] | None = Field(
|
|
44
|
+
default=None, description="Expected value range"
|
|
45
|
+
)
|
|
46
|
+
threshold: float | None = Field(default=None, description="Detection threshold used")
|
|
47
|
+
message: str = Field(description="Human-readable description")
|
|
48
|
+
metadata: dict[str, Any] = Field(default_factory=dict, description="Additional context")
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
"""String representation."""
|
|
52
|
+
return f"[{self.severity.value.upper()}] {self.symbol} @ {self.timestamp}: {self.message}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AnomalyDetector(ABC):
|
|
56
|
+
"""Abstract base class for anomaly detectors."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, enabled: bool = True):
|
|
59
|
+
"""
|
|
60
|
+
Initialize detector.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
enabled: Whether this detector is enabled
|
|
64
|
+
"""
|
|
65
|
+
self.enabled = enabled
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def detect(self, df: pl.DataFrame, symbol: str) -> list[Anomaly]:
|
|
69
|
+
"""
|
|
70
|
+
Detect anomalies in the data.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
df: DataFrame with OHLCV data
|
|
74
|
+
symbol: Symbol being analyzed
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of detected anomalies
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def name(self) -> str:
|
|
83
|
+
"""Return detector name."""
|
|
84
|
+
|
|
85
|
+
def is_enabled(self) -> bool:
|
|
86
|
+
"""Check if detector is enabled."""
|
|
87
|
+
return self.enabled
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AnomalyReport(BaseModel):
|
|
91
|
+
"""Anomaly detection report."""
|
|
92
|
+
|
|
93
|
+
symbol: str = Field(description="Symbol analyzed")
|
|
94
|
+
start_date: datetime = Field(description="Start of analysis period")
|
|
95
|
+
end_date: datetime = Field(description="End of analysis period")
|
|
96
|
+
total_rows: int = Field(description="Total data points analyzed")
|
|
97
|
+
anomalies: list[Anomaly] = Field(default_factory=list, description="Detected anomalies")
|
|
98
|
+
summary: dict[str, int] = Field(default_factory=dict, description="Summary statistics")
|
|
99
|
+
detectors_used: list[str] = Field(default_factory=list, description="Detectors applied")
|
|
100
|
+
|
|
101
|
+
def add_anomaly(self, anomaly: Anomaly) -> None:
|
|
102
|
+
"""Add an anomaly to the report."""
|
|
103
|
+
self.anomalies.append(anomaly)
|
|
104
|
+
|
|
105
|
+
# Update summary
|
|
106
|
+
severity_key = f"{anomaly.severity.value}_count"
|
|
107
|
+
self.summary[severity_key] = self.summary.get(severity_key, 0) + 1
|
|
108
|
+
|
|
109
|
+
type_key = f"{anomaly.type.value}_count"
|
|
110
|
+
self.summary[type_key] = self.summary.get(type_key, 0) + 1
|
|
111
|
+
|
|
112
|
+
def get_critical_anomalies(self) -> list[Anomaly]:
|
|
113
|
+
"""Get only critical anomalies."""
|
|
114
|
+
return [a for a in self.anomalies if a.severity == AnomalySeverity.CRITICAL]
|
|
115
|
+
|
|
116
|
+
def get_by_type(self, anomaly_type: AnomalyType) -> list[Anomaly]:
|
|
117
|
+
"""Get anomalies of specific type."""
|
|
118
|
+
return [a for a in self.anomalies if a.type == anomaly_type]
|
|
119
|
+
|
|
120
|
+
def has_critical_issues(self) -> bool:
|
|
121
|
+
"""Check if there are any critical issues."""
|
|
122
|
+
return any(a.severity == AnomalySeverity.CRITICAL for a in self.anomalies)
|
|
123
|
+
|
|
124
|
+
def to_dataframe(self) -> pl.DataFrame:
|
|
125
|
+
"""Convert anomalies to DataFrame for analysis."""
|
|
126
|
+
if not self.anomalies:
|
|
127
|
+
return pl.DataFrame()
|
|
128
|
+
|
|
129
|
+
records = []
|
|
130
|
+
for anomaly in self.anomalies:
|
|
131
|
+
records.append(
|
|
132
|
+
{
|
|
133
|
+
"timestamp": anomaly.timestamp,
|
|
134
|
+
"symbol": anomaly.symbol,
|
|
135
|
+
"type": anomaly.type.value,
|
|
136
|
+
"severity": anomaly.severity.value,
|
|
137
|
+
"value": anomaly.value,
|
|
138
|
+
"message": anomaly.message,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return pl.DataFrame(records)
|