
Ohlcv Processing
- 208 installs
- 257 repo stars
- Updated June 24, 2026
- agiprolabs/claude-trading-skills
ohlcv-processing is a Claude Code skill that validates, cleans, resamples, and merges crypto OHLCV market data as the first step of a trading analysis pipeline.
About
ohlcv-processing is a Claude Code skill that prepares crypto market data for trading analysis. It validates OHLCV DataFrames, handles gaps, detects impossible candles and anomalies, resamples timeframes, and merges data from multiple sources. A developer runs it as the first step of any trading or backtesting workflow to avoid corrupted indicators and misleading results.
- Full OHLCV pipeline: validation, gap handling, anomaly detection, resampling, multi-source merging
- Handles crypto-specific data mess: 24/7 trading, DEX aggregator disagreement, API gaps
- Ships process_ohlcv.py and merge_sources.py with Birdeye API and demo modes
Ohlcv Processing by the numbers
- 208 all-time installs (skills.sh)
- Ranked #453 of 1,106 Finance & Trading skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
ohlcv-processing capabilities & compatibility
Free skill; optional Birdeye API key only to fetch live data, demo mode needs none.
- Capabilities
- data cleaning · market data processing · resampling · anomaly detection · multi source merge
- Use cases
- data analysis
- Pricing
- Bring your own API key
What ohlcv-processing says it does
Clean, consistent OHLCV data is the foundation of every trading analysis. Garbage in, garbage out
This skill covers the full data preparation pipeline: validation, cleaning, resampling, normalization, and multi-source merging.
npx skills add https://github.com/agiprolabs/claude-trading-skills --skill ohlcv-processingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 208 |
|---|---|
| repo stars | ★ 257 |
| Last updated | June 24, 2026 |
| Repository | agiprolabs/claude-trading-skills ↗ |
What it does
Clean, validate, resample and merge crypto OHLCV candle data before running any trading analysis.
Who is it for?
Preparing raw crypto candle data before computing indicators or running a backtest.
Skip if: Live order execution or strategy signal generation.
When should I use this skill?
You have raw OHLCV candles and need them validated, gap-filled, and normalized before analysis.
What you get
A clean, canonical OHLCV DataFrame ready for technical analysis or backtesting.
- Cleaned OHLCV DataFrame
- Anomaly report of impossible candles
- Merged multi-source dataset
By the numbers
- 5-step processing pipeline (standardize, validate, gap-fill, anomaly-flag, resample)
- 5 required OHLCV columns
Files
OHLCV Processing — Market Data Preparation
Clean, consistent OHLCV data is the foundation of every trading analysis. Garbage in, garbage out — a single anomalous candle can trigger false signals, corrupt indicator calculations, and produce misleading backtest results. This skill covers the full data preparation pipeline: validation, cleaning, resampling, normalization, and multi-source merging.
Why this matters: Crypto OHLCV data is messier than traditional markets. 24/7 trading means no official close, DEX aggregators disagree on prices, low-liquidity tokens produce impossible candles, and API outages create gaps. Every analysis workflow should start with this pipeline.
Quick Start
1. Install Dependencies
uv pip install pandas numpy httpx2. Standard OHLCV DataFrame Format
All processing functions expect this canonical format:
import pandas as pd
# Canonical OHLCV DataFrame
# - DatetimeIndex in UTC
# - Columns: open, high, low, close, volume (lowercase)
# - Sorted ascending by timestamp
# - No duplicate timestamps
df = pd.DataFrame({
"open": [1.10, 1.12, 1.11],
"high": [1.15, 1.14, 1.13],
"low": [1.08, 1.10, 1.09],
"close": [1.12, 1.11, 1.12],
"volume": [50000, 48000, 52000],
}, index=pd.to_datetime([
"2025-01-01 00:00:00",
"2025-01-01 00:01:00",
"2025-01-01 00:02:00",
], utc=True))
df.index.name = "timestamp"3. Full Processing Pipeline
import pandas as pd
import numpy as np
def process_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
"""Run complete OHLCV processing pipeline."""
df = standardize_columns(df)
df = validate_ohlcv(df)
df = handle_gaps(df, method="ffill")
df = detect_and_flag_anomalies(df)
return dfData Validation
Column Checks
REQUIRED_COLUMNS = {"open", "high", "low", "close", "volume"}
def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Normalize column names to lowercase standard."""
df.columns = df.columns.str.lower().str.strip()
# Common renames
rename_map = {"vol": "volume", "v": "volume", "o": "open",
"h": "high", "l": "low", "c": "close"}
df = df.rename(columns=rename_map)
missing = REQUIRED_COLUMNS - set(df.columns)
if missing:
raise ValueError(f"Missing columns: {missing}")
return df[["open", "high", "low", "close", "volume"]]Structural Validation
def validate_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
"""Validate OHLCV structural integrity."""
# Ensure DatetimeIndex in UTC
if not isinstance(df.index, pd.DatetimeIndex):
df.index = pd.to_datetime(df.index, utc=True)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
# Sort and deduplicate
df = df.sort_index()
dupes = df.index.duplicated(keep="last")
if dupes.any():
print(f"Warning: Removed {dupes.sum()} duplicate timestamps")
df = df[~dupes]
# Type enforcement
for col in ["open", "high", "low", "close", "volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
return dfImpossible Candle Detection
def find_impossible_candles(df: pd.DataFrame) -> pd.DataFrame:
"""Find candles that violate OHLC constraints."""
issues = pd.DataFrame(index=df.index)
issues["high_lt_low"] = df["high"] < df["low"]
issues["high_lt_open"] = df["high"] < df["open"]
issues["high_lt_close"] = df["high"] < df["close"]
issues["low_gt_open"] = df["low"] > df["open"]
issues["low_gt_close"] = df["low"] > df["close"]
issues["negative_price"] = (df[["open", "high", "low", "close"]] < 0).any(axis=1)
issues["negative_volume"] = df["volume"] < 0
issues["any_issue"] = issues.any(axis=1)
return issues[issues["any_issue"]]Gap Handling
Crypto trades 24/7, but gaps still occur from API outages, low liquidity, or aggregator downtime.
Detect Gaps
def detect_gaps(df: pd.DataFrame, expected_freq: str = "1min") -> pd.Series:
"""Find missing timestamps based on expected frequency."""
full_index = pd.date_range(
start=df.index.min(), end=df.index.max(), freq=expected_freq, tz="UTC"
)
missing = full_index.difference(df.index)
return missingFill Gaps
def handle_gaps(
df: pd.DataFrame,
freq: str = "1min",
method: str = "ffill",
max_gap: int = 5,
) -> pd.DataFrame:
"""Fill gaps in OHLCV data.
Args:
df: OHLCV DataFrame with DatetimeIndex.
freq: Expected bar frequency.
method: 'ffill' (forward fill) or 'interpolate'.
max_gap: Maximum consecutive bars to fill. Larger gaps are left as NaN.
"""
full_index = pd.date_range(
start=df.index.min(), end=df.index.max(), freq=freq, tz="UTC"
)
df = df.reindex(full_index)
df.index.name = "timestamp"
# Mark which bars were filled
df["is_filled"] = df["close"].isna()
if method == "ffill":
# Forward fill OHLC (flat candle), zero volume
df[["open", "high", "low", "close"]] = (
df[["open", "high", "low", "close"]].ffill(limit=max_gap)
)
df["volume"] = df["volume"].fillna(0)
elif method == "interpolate":
df[["open", "high", "low", "close"]] = (
df[["open", "high", "low", "close"]].interpolate(
method="time", limit=max_gap
)
)
df["volume"] = df["volume"].fillna(0)
return dfAnomaly Detection
See references/data_quality.md for the complete anomaly taxonomy.
Price Spike Detection
def detect_price_spikes(
df: pd.DataFrame, window: int = 20, threshold: float = 3.0
) -> pd.Series:
"""Flag bars where return exceeds threshold * rolling std."""
returns = df["close"].pct_change()
rolling_std = returns.rolling(window, min_periods=5).std()
spike = returns.abs() > (threshold * rolling_std)
return spike.fillna(False)Zero Volume Detection
def detect_zero_volume(df: pd.DataFrame, min_volume: float = 0) -> pd.Series:
"""Flag bars with zero or below-minimum volume."""
return df["volume"] <= min_volumeComposite Anomaly Flagging
def flag_anomalies(df: pd.DataFrame) -> pd.DataFrame:
"""Add anomaly flag columns to DataFrame."""
df["anomaly_spike"] = detect_price_spikes(df)
df["anomaly_zero_vol"] = detect_zero_volume(df)
impossible = find_impossible_candles(df)
df["anomaly_impossible"] = False
if not impossible.empty:
df.loc[impossible.index, "anomaly_impossible"] = True
df["anomaly_any"] = (
df["anomaly_spike"] | df["anomaly_zero_vol"] | df["anomaly_impossible"]
)
return dfResampling
See references/resampling_guide.md for detailed guidance.
Standard Resample
OHLCV_RESAMPLE_RULES = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
def resample_ohlcv(df: pd.DataFrame, target_freq: str) -> pd.DataFrame:
"""Resample OHLCV to a coarser timeframe.
Args:
df: OHLCV DataFrame (must be finer than target_freq).
target_freq: Pandas frequency string ('5min', '15min', '1h', '4h', '1D').
Returns:
Resampled OHLCV DataFrame with no NaN rows.
"""
ohlcv_cols = ["open", "high", "low", "close", "volume"]
resampled = df[ohlcv_cols].resample(target_freq).agg(OHLCV_RESAMPLE_RULES)
return resampled.dropna(subset=["close"])Common Timeframe Ladder
TIMEFRAME_LADDER = ["1min", "5min", "15min", "1h", "4h", "1D"]
def resample_ladder(df: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""Resample 1-minute data to all standard timeframes."""
results = {"1min": df.copy()}
for tf in TIMEFRAME_LADDER[1:]:
results[tf] = resample_ohlcv(df, tf)
return resultsVWAP Calculation
def compute_vwap(df: pd.DataFrame) -> pd.Series:
"""Compute cumulative VWAP over the DataFrame."""
typical_price = (df["high"] + df["low"] + df["close"]) / 3
cum_vol = df["volume"].cumsum()
cum_tp_vol = (typical_price * df["volume"]).cumsum()
return cum_tp_vol / cum_volNormalization
def normalize_prices(
df: pd.DataFrame, method: str = "returns"
) -> pd.DataFrame:
"""Normalize OHLCV price columns.
Methods:
'returns' — Percentage returns (close-to-close).
'log_returns' — Log returns.
'minmax' — Min-max scale to [0, 1].
'zscore' — Z-score normalization.
"""
price_cols = ["open", "high", "low", "close"]
result = df.copy()
if method == "returns":
for col in price_cols:
result[f"{col}_ret"] = result[col].pct_change()
elif method == "log_returns":
for col in price_cols:
result[f"{col}_logret"] = np.log(result[col] / result[col].shift(1))
elif method == "minmax":
for col in price_cols:
cmin, cmax = result[col].min(), result[col].max()
result[f"{col}_norm"] = (result[col] - cmin) / (cmax - cmin)
elif method == "zscore":
for col in price_cols:
result[f"{col}_z"] = (
(result[col] - result[col].mean()) / result[col].std()
)
return resultMulti-Source Merging
When combining data from multiple sources (e.g., Birdeye + DexScreener), timestamps may not align and prices may differ due to different DEX aggregation.
def merge_ohlcv_sources(
primary: pd.DataFrame,
secondary: pd.DataFrame,
tolerance: str = "30s",
) -> pd.DataFrame:
"""Merge two OHLCV sources, preferring the higher-volume source per bar.
Args:
primary: First OHLCV source.
secondary: Second OHLCV source.
tolerance: Maximum time difference for alignment.
"""
merged = pd.merge_asof(
primary.sort_index(),
secondary.sort_index(),
left_index=True, right_index=True,
tolerance=pd.Timedelta(tolerance),
suffixes=("_pri", "_sec"),
)
# Use higher-volume source per bar
use_secondary = merged["volume_sec"] > merged["volume_pri"]
for col in ["open", "high", "low", "close", "volume"]:
merged[col] = np.where(
use_secondary, merged[f"{col}_sec"], merged[f"{col}_pri"]
)
merged["source"] = np.where(use_secondary, "secondary", "primary")
return merged[["open", "high", "low", "close", "volume", "source"]]Timezone Handling
Standard: Always store and process in UTC. Convert only for display.
def ensure_utc(df: pd.DataFrame) -> pd.DataFrame:
"""Ensure DatetimeIndex is UTC."""
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
elif str(df.index.tz) != "UTC":
df.index = df.index.tz_convert("UTC")
return dfData Quality Report
def quality_report(df: pd.DataFrame) -> dict:
"""Generate a data quality summary."""
total = len(df)
return {
"total_bars": total,
"date_range": f"{df.index.min()} → {df.index.max()}",
"missing_values": int(df[["open", "high", "low", "close"]].isna().sum().sum()),
"zero_volume_bars": int((df["volume"] == 0).sum()),
"impossible_candles": int((df["high"] < df["low"]).sum()),
"duplicate_timestamps": int(df.index.duplicated().sum()),
"negative_prices": int((df[["open", "high", "low", "close"]] < 0).any(axis=1).sum()),
"completeness_pct": round((1 - df["close"].isna().mean()) * 100, 2),
}Files
References
references/data_quality.md— Anomaly types, detection methods, correction strategies, crypto-specific data issuesreferences/resampling_guide.md— Resample rules, timeframe use cases, partial bar handling, VWAP resampling, multi-timeframe alignment
Scripts
scripts/process_ohlcv.py— Full processing pipeline: validate, clean, resample, normalize with anomaly reporting (run with--demofor synthetic data)scripts/merge_sources.py— Multi-source OHLCV merging with conflict resolution and discrepancy reporting (run with--demo)
OHLCV Data Quality — Anomalies, Detection & Correction
Why Data Quality Matters
A single bad candle can corrupt rolling indicators for dozens of bars downstream. A 100x price spike that lasts one bar will blow out Bollinger Bands, RSI, and any volatility estimate for the entire lookback window. In backtesting, bad data produces phantom signals and unrealistic P&L. Always validate before analysis.
Anomaly Taxonomy
1. Price Spikes
What: A single bar shows a return exceeding 3+ standard deviations from the rolling mean, then immediately reverts.
Causes: DEX aggregator picking up a thin-liquidity pool, API returning a stale/wrong price, flash loan manipulation.
Detection:
returns = df["close"].pct_change()
rolling_std = returns.rolling(20, min_periods=5).std()
spikes = returns.abs() > (3.0 * rolling_std)Correction strategies:
- Flag only: Add
anomaly_spike=Truecolumn, let downstream code decide - Interpolate: Replace OHLC with linear interpolation from neighbors
- Clip: Cap returns at ±N standard deviations
- Remove: Drop the bar entirely (shifts timestamps — use with caution)
Recommendation: Flag and interpolate for analysis. Remove for backtesting only if the spike is confirmed as data error (not a real market event).
2. Zero Volume Bars
What: Bars where volume == 0 despite having price data.
Causes: Low-liquidity token with no trades in that interval, API returning placeholder candles, data source gap-filling with zero volume.
Detection:
zero_vol = df["volume"] == 0Correction strategies:
- Keep: Zero volume is informative — it means no trading happened
- Forward fill volume: Misleading, avoid
- Mark: Add
is_zero_volume=Trueflag for filters
Recommendation: Keep zero-volume bars but flag them. Exclude from volume-dependent indicators (VWAP, OBV, volume profile).
3. Impossible Candles (high < low)
What: A bar where high < low, high < open, high < close, low > open, or low > close.
Causes: Data source bug, incorrect aggregation, API returning fields in wrong order.
Detection:
impossible = (
(df["high"] < df["low"]) |
(df["high"] < df["open"]) |
(df["high"] < df["close"]) |
(df["low"] > df["open"]) |
(df["low"] > df["close"])
)Correction strategies:
- Recalculate: Set
high = max(open, high, low, close)andlow = min(open, high, low, close) - Remove: Drop the bar if correction is unreliable
Recommendation: Recalculate high/low from the four price fields. This fixes most cases caused by field ordering bugs.
4. Negative Prices
What: Any price field is negative.
Causes: Data corruption, integer overflow in source data, bad type conversion.
Detection:
negative = (df[["open", "high", "low", "close"]] < 0).any(axis=1)Correction: Remove these bars. Negative prices are never valid for spot crypto.
5. Duplicate Timestamps
What: Two or more bars share the same timestamp.
Causes: API pagination overlap, multiple data source concatenation without dedup, timezone conversion errors creating apparent duplicates.
Detection:
dupes = df.index.duplicated(keep=False) # Marks all duplicatesCorrection strategies:
- Keep last: Most recent data is usually more accurate (
keep="last") - Keep highest volume: The bar with more volume likely represents more trades
- Average: Mean of duplicate bars (only for close-valued duplicates)
Recommendation: Keep the bar with highest volume if values differ significantly. Keep last if values are similar.
6. Stale Data (Flat Lines)
What: Multiple consecutive bars with identical OHLC values (open == high == low == close).
Causes: No trades occurred, data source repeating last known price, API caching.
Detection:
flat = (
(df["open"] == df["high"]) &
(df["high"] == df["low"]) &
(df["low"] == df["close"])
)
consecutive_flat = flat.rolling(5).sum() >= 5Correction: Flag but keep. Consecutive flat bars in low-liquidity tokens are real — they indicate no trading activity.
7. Extreme Spread Candles
What: (high - low) / close exceeds a threshold (e.g., > 50% for a single bar).
Causes: Real flash crash/pump, aggregation across pools with very different prices, data error.
Detection:
spread = (df["high"] - df["low"]) / df["close"]
extreme = spread > 0.5 # 50% spread in one barCorrection: Verify against on-chain data. If confirmed as real, keep. If data error, interpolate.
Validation Pipeline
Run these checks in order before any analysis:
def validate_pipeline(df: pd.DataFrame) -> dict:
"""Run all validation checks and return a report."""
report = {}
report["total_bars"] = len(df)
report["duplicate_timestamps"] = int(df.index.duplicated().sum())
report["negative_prices"] = int(
(df[["open", "high", "low", "close"]] < 0).any(axis=1).sum()
)
report["impossible_candles"] = int((df["high"] < df["low"]).sum())
report["zero_volume_bars"] = int((df["volume"] == 0).sum())
returns = df["close"].pct_change()
rolling_std = returns.rolling(20, min_periods=5).std()
report["price_spikes_3sigma"] = int(
(returns.abs() > 3 * rolling_std).sum()
)
flat = (df["open"] == df["high"]) & (df["high"] == df["low"]) & (df["low"] == df["close"])
report["flat_candles"] = int(flat.sum())
report["nan_values"] = int(
df[["open", "high", "low", "close", "volume"]].isna().sum().sum()
)
return reportCrypto-Specific Considerations
24/7 Trading
- There is no official daily close. "Daily" candles use UTC midnight by convention.
- No weekends or holidays — gaps are always data issues, never market closures.
- Expect higher volume during US/EU trading hours even for crypto.
No Single Source of Truth
- Unlike equities with a consolidated tape, crypto prices vary by DEX, aggregator, and pool.
- Two sources can legitimately disagree by 0.1–2% on the same timestamp.
- Volume figures are especially unreliable — wash trading inflates DEX volumes.
Exchange/Aggregator Differences
- Birdeye aggregates across all Solana DEXes — broadest coverage.
- DexScreener may weight pools differently.
- On-chain data (Helius/Solana RPC) is ground truth but requires reconstruction.
Token Lifecycle Issues
- New tokens may have minutes of data, then nothing.
- Rug pulls create a final catastrophic candle that is real data, not an anomaly.
- Token migrations (v1 → v2) create apparent price discontinuities.
Precision
- Solana tokens can have 0–9 decimals. A token with 0 decimals has integer-only prices.
- Very low-priced tokens (< $0.000001) need float64 precision at minimum.
- Always use
float64for price columns, neverfloat32.
OHLCV Resampling Guide
Core Resample Rules
When aggregating fine-grained candles into coarser timeframes, each OHLCV field has a specific aggregation rule:
| Field | Aggregation | Rationale |
|---|---|---|
open | first | Opening price of the period is the first trade |
high | max | Highest price across all sub-bars |
low | min | Lowest price across all sub-bars |
close | last | Closing price is the last trade |
volume | sum | Total volume traded in the period |
OHLCV_AGG = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
resampled = df.resample("1h").agg(OHLCV_AGG).dropna(subset=["close"])Critical: Always dropna(subset=["close"]) after resampling. Periods with no data produce all-NaN rows that contaminate downstream calculations.
Common Timeframes
| Frequency | Pandas Code | Typical Use Case |
|---|---|---|
| 1 minute | 1min | Scalping, microstructure analysis, raw data |
| 5 minutes | 5min | Short-term momentum, intraday patterns |
| 15 minutes | 15min | Intraday trading, common chart timeframe |
| 1 hour | 1h | Swing entry/exit, indicator calculation |
| 4 hours | 4h | Swing trading, trend identification |
| 1 day | 1D | Daily analysis, portfolio rebalancing |
| 1 week | 1W | Long-term trend, macro analysis |
Timeframe Selection Guidelines
- Indicator calculation: Use the timeframe the indicator was designed for. RSI-14 on 1-minute bars is very different from RSI-14 on daily bars.
- Backtesting: Match the timeframe to your trading frequency. If you trade once per day, use daily bars.
- Multi-timeframe analysis: Typically combine 3 timeframes — e.g., 15m (entry timing), 1h (trend direction), 4h (macro context).
Handling Partial Bars at Boundaries
When resampling, the first and last bars in the dataset may be partial (e.g., resampling to 1h but data starts at 10:23).
Problem
# Data starts at 10:23, resampling to 1h
# The 10:00-11:00 bar only contains 37 minutes of data
# This partial bar has lower volume and may have misleading OHLCSolutions
Drop partial bars (recommended for backtesting):
def resample_drop_partial(
df: pd.DataFrame, freq: str
) -> pd.DataFrame:
"""Resample and drop the first/last bar if partial."""
resampled = df.resample(freq).agg(OHLCV_AGG).dropna(subset=["close"])
# Count source bars per resampled bar
bar_counts = df["close"].resample(freq).count()
expected = bar_counts.median()
# Drop bars with significantly fewer source bars
full_bars = bar_counts >= (expected * 0.8)
return resampled[full_bars]Keep and flag (recommended for live data):
bar_counts = df["close"].resample(freq).count()
resampled["bar_count"] = bar_counts
resampled["is_partial"] = bar_counts < bar_counts.median() * 0.8VWAP-Weighted Resampling
Standard OHLCV resampling treats all sub-bars equally. For a volume-weighted view, compute VWAP alongside standard resampling.
Session VWAP
def compute_session_vwap(df: pd.DataFrame, session_freq: str = "1D") -> pd.Series:
"""Compute VWAP that resets each session (e.g., daily)."""
typical_price = (df["high"] + df["low"] + df["close"]) / 3
tp_vol = typical_price * df["volume"]
cum_tp_vol = tp_vol.groupby(tp_vol.index.floor(session_freq)).cumsum()
cum_vol = df["volume"].groupby(df["volume"].index.floor(session_freq)).cumsum()
return cum_tp_vol / cum_volRolling VWAP
def rolling_vwap(df: pd.DataFrame, window: int = 20) -> pd.Series:
"""Compute rolling VWAP over N bars."""
typical_price = (df["high"] + df["low"] + df["close"]) / 3
tp_vol = typical_price * df["volume"]
return tp_vol.rolling(window).sum() / df["volume"].rolling(window).sum()Multi-Timeframe Alignment
When combining indicators from multiple timeframes, alignment is critical. A 4h indicator value must be assigned to the correct 1h or 15m bars.
Forward-Fill Method (standard)
The higher-timeframe value applies to all lower-timeframe bars within that period, using the previous completed higher-timeframe bar (no lookahead).
def align_timeframes(
base_df: pd.DataFrame,
higher_tf_series: pd.Series,
) -> pd.Series:
"""Align a higher-timeframe series to a lower-timeframe index.
Uses forward-fill to avoid lookahead bias: each bar gets the
most recently completed higher-timeframe value.
"""
# Shift higher TF by one period to avoid lookahead
higher_shifted = higher_tf_series.shift(1)
# Reindex to base timeframe and forward fill
aligned = higher_shifted.reindex(base_df.index, method="ffill")
return alignedExample: 15m Base with 4h Trend
df_15m = resample_ohlcv(df_1m, "15min")
df_4h = resample_ohlcv(df_1m, "4h")
# Compute 4h SMA
sma_4h = df_4h["close"].rolling(20).mean()
# Align to 15m without lookahead
df_15m["sma_4h"] = align_timeframes(df_15m, sma_4h)Resampling Pitfalls
1. Lookahead Bias
Using the current (incomplete) higher-timeframe bar for decisions. Always shift by one period or use only completed bars.
2. Volume Inflation
When resampling up (1m → 1h), volume sums correctly. When resampling down (1h → 1m), distributing volume across sub-bars is lossy and unreliable. Only resample from fine to coarse.
3. Timezone Mismatch
If your data is in UTC but you resample with 1D, the daily boundary is UTC midnight. If your strategy uses US market hours, set offset in the resample call:
# US Eastern daily bars (market close = 20:00 UTC in winter)
df.resample("1D", offset="20h").agg(OHLCV_AGG)4. Missing Sub-Bars
If 30 out of 60 expected 1-minute bars are missing in an hour, the 1h candle is computed from only half the data. The high and low may understate the true range. Track bar_count to assess quality.
5. Pandas Frequency Aliases
Common aliases and their meaning:
1minorT— 1 minute5minor5T— 5 minutes1horH— 1 hour4hor4H— 4 hours1DorD— 1 calendar day1WorW— 1 week (ends Sunday by default)1MS— 1 month start1ME— 1 month end
Note: Pandas 2.x deprecated H in favor of h, and T in favor of min. Use the full-word forms for forward compatibility.
Resampling with Additional Columns
If your DataFrame has extra columns (e.g., vwap, trade_count, is_filled), define aggregation rules for each:
EXTENDED_AGG = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
"trade_count": "sum",
"vwap": "last", # Or recompute from resampled data
"is_filled": "any", # True if any sub-bar was filled
}#!/usr/bin/env python3
"""Multi-source OHLCV merger: align, reconcile, and merge data from two sources.
Demonstrates merging OHLCV data from two different providers (e.g., Birdeye
and DexScreener). Aligns timestamps, resolves price conflicts by preferring
the higher-volume source, and reports discrepancies.
Usage:
python scripts/merge_sources.py --demo
python scripts/merge_sources.py --source1 birdeye.csv --source2 dexscreener.csv
Dependencies:
uv pip install pandas numpy
Environment Variables:
None required (demo mode uses synthetic data).
"""
import argparse
import sys
from typing import Optional
import numpy as np
import pandas as pd
# ── Configuration ───────────────────────────────────────────────────
REQUIRED_COLUMNS = {"open", "high", "low", "close", "volume"}
# Maximum acceptable price discrepancy between sources (percentage)
MAX_PRICE_DISCREPANCY_PCT = 5.0
# Timestamp alignment tolerance
DEFAULT_TOLERANCE = "30s"
# ── Demo Data Generation ───────────────────────────────────────────
def generate_source_pair(
bars: int = 500,
seed: int = 42,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Generate two synthetic OHLCV sources with realistic differences.
Simulates two data providers that mostly agree but differ due to:
- Different DEX pool weighting (slight price differences)
- Different volume reporting (can vary 10-30%)
- Missing bars in each source (different gaps)
- Slight timestamp offsets
Args:
bars: Number of bars per source.
seed: Random seed for reproducibility.
Returns:
Tuple of (source1, source2) DataFrames.
"""
rng = np.random.default_rng(seed)
# Base price series
timestamps = pd.date_range(
start="2025-06-01 00:00:00", periods=bars, freq="1min", tz="UTC"
)
returns = rng.normal(0.0001, 0.003, size=bars)
base_close = 50.0 * np.cumprod(1 + returns)
# Source 1: "Birdeye-like" — broader coverage, slightly different prices
spread1 = rng.uniform(0.001, 0.004, size=bars)
s1_close = base_close * (1 + rng.normal(0, 0.001, size=bars))
s1_high = s1_close * (1 + spread1)
s1_low = s1_close * (1 - spread1)
s1_open = s1_close * (1 + rng.normal(0, 0.0015, size=bars))
s1_high = np.maximum(s1_high, np.maximum(s1_open, s1_close))
s1_low = np.minimum(s1_low, np.minimum(s1_open, s1_close))
s1_volume = rng.exponential(100000, size=bars)
source1 = pd.DataFrame({
"open": s1_open, "high": s1_high,
"low": s1_low, "close": s1_close,
"volume": s1_volume,
}, index=timestamps)
source1.index.name = "timestamp"
# Source 2: "DexScreener-like" — different pools, volume variation
price_offset = rng.normal(0, 0.002, size=bars)
s2_close = base_close * (1 + price_offset)
spread2 = rng.uniform(0.001, 0.005, size=bars)
s2_high = s2_close * (1 + spread2)
s2_low = s2_close * (1 - spread2)
s2_open = s2_close * (1 + rng.normal(0, 0.002, size=bars))
s2_high = np.maximum(s2_high, np.maximum(s2_open, s2_close))
s2_low = np.minimum(s2_low, np.minimum(s2_open, s2_close))
# Volume differs by 10-30%
vol_factor = rng.uniform(0.7, 1.3, size=bars)
s2_volume = s1_volume * vol_factor
source2 = pd.DataFrame({
"open": s2_open, "high": s2_high,
"low": s2_low, "close": s2_close,
"volume": s2_volume,
}, index=timestamps)
source2.index.name = "timestamp"
# Create different gaps in each source
# Source 1: missing bars 100-105
s1_drop = list(range(100, 106))
source1 = source1.drop(source1.index[s1_drop])
# Source 2: missing bars 200-210
s2_drop = list(range(200, 211))
source2 = source2.drop(source2.index[s2_drop])
# Add a few large discrepancy bars to source 2 (simulating pool-specific events)
if len(source2) > 300:
source2.iloc[300, source2.columns.get_loc("close")] *= 1.08
source2.iloc[300, source2.columns.get_loc("high")] *= 1.10
print(f"Generated source 1: {len(source1)} bars")
print(f"Generated source 2: {len(source2)} bars")
return source1, source2
# ── Validation ──────────────────────────────────────────────────────
def validate_source(df: pd.DataFrame, name: str) -> pd.DataFrame:
"""Validate a single OHLCV source.
Args:
df: Raw OHLCV DataFrame.
name: Source name for logging.
Returns:
Validated DataFrame.
Raises:
ValueError: If required columns are missing.
"""
df = df.copy()
df.columns = df.columns.str.lower().str.strip()
missing = REQUIRED_COLUMNS - set(df.columns)
if missing:
raise ValueError(f"Source '{name}' missing columns: {missing}")
# Ensure DatetimeIndex in UTC
if not isinstance(df.index, pd.DatetimeIndex):
df.index = pd.to_datetime(df.index, utc=True)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
df = df.sort_index()
dupes = df.index.duplicated(keep="last")
if dupes.any():
print(f" [{name}] Removed {dupes.sum()} duplicate(s)")
df = df[~dupes]
for col in ["open", "high", "low", "close", "volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df[["open", "high", "low", "close", "volume"]]
# ── Discrepancy Analysis ──────────────────────────────────────────
def analyze_discrepancies(
source1: pd.DataFrame,
source2: pd.DataFrame,
name1: str = "source1",
name2: str = "source2",
) -> pd.DataFrame:
"""Analyze price and volume discrepancies between two aligned sources.
Args:
source1: First OHLCV source (aligned timestamps).
source2: Second OHLCV source (aligned timestamps).
name1: Label for source 1.
name2: Label for source 2.
Returns:
DataFrame with discrepancy metrics per overlapping bar.
"""
# Find overlapping timestamps
common_idx = source1.index.intersection(source2.index)
if len(common_idx) == 0:
print(" WARNING: No overlapping timestamps between sources")
return pd.DataFrame()
s1 = source1.loc[common_idx]
s2 = source2.loc[common_idx]
disc = pd.DataFrame(index=common_idx)
disc.index.name = "timestamp"
# Price discrepancy (close-to-close, percentage)
disc["close_pct_diff"] = ((s1["close"] - s2["close"]) / s1["close"] * 100).round(4)
disc["close_abs_diff"] = (s1["close"] - s2["close"]).abs().round(6)
# Volume discrepancy (percentage)
vol_mean = (s1["volume"] + s2["volume"]) / 2
disc["volume_pct_diff"] = (
((s1["volume"] - s2["volume"]) / vol_mean.replace(0, np.nan)) * 100
).round(2)
# High/Low range comparison
s1_range = (s1["high"] - s1["low"]) / s1["close"] * 100
s2_range = (s2["high"] - s2["low"]) / s2["close"] * 100
disc["range_diff_pct"] = (s1_range - s2_range).round(4)
# Flag large discrepancies
disc["large_price_disc"] = disc["close_pct_diff"].abs() > MAX_PRICE_DISCREPANCY_PCT
disc["large_vol_disc"] = disc["volume_pct_diff"].abs() > 50.0
return disc
def print_discrepancy_report(
disc: pd.DataFrame,
name1: str = "source1",
name2: str = "source2",
) -> None:
"""Print a summary of discrepancies between two sources.
Args:
disc: Discrepancy DataFrame from analyze_discrepancies().
name1: Label for source 1.
name2: Label for source 2.
"""
if disc.empty:
print(" No overlapping data to compare.")
return
print("\n" + "=" * 60)
print(f" DISCREPANCY REPORT: {name1} vs {name2}")
print("=" * 60)
total = len(disc)
print(f" Overlapping bars: {total}")
# Close price stats
close_diff = disc["close_pct_diff"]
print(f"\n Close Price Discrepancy (%):")
print(f" Mean: {close_diff.mean():.4f}%")
print(f" Std Dev: {close_diff.std():.4f}%")
print(f" Max Absolute: {close_diff.abs().max():.4f}%")
print(f" Bars > {MAX_PRICE_DISCREPANCY_PCT}%: "
f"{disc['large_price_disc'].sum()}")
# Volume stats
vol_diff = disc["volume_pct_diff"].dropna()
print(f"\n Volume Discrepancy (%):")
print(f" Mean: {vol_diff.mean():.2f}%")
print(f" Std Dev: {vol_diff.std():.2f}%")
print(f" Bars > 50%: {disc['large_vol_disc'].sum()}")
# Show worst discrepancies
large = disc[disc["large_price_disc"]]
if not large.empty:
print(f"\n Large Price Discrepancies ({len(large)} bars):")
for ts, row in large.head(5).iterrows():
print(f" {ts}: {row['close_pct_diff']:+.4f}% "
f"(abs diff: {row['close_abs_diff']:.6f})")
if len(large) > 5:
print(f" ... and {len(large) - 5} more")
print("=" * 60)
# ── Merging ────────────────────────────────────────────────────────
def merge_ohlcv_sources(
source1: pd.DataFrame,
source2: pd.DataFrame,
tolerance: str = DEFAULT_TOLERANCE,
prefer: str = "higher_volume",
) -> pd.DataFrame:
"""Merge two OHLCV sources into a single clean dataset.
Strategy:
1. Create a union of all timestamps from both sources.
2. For overlapping timestamps, prefer the source with higher volume.
3. For non-overlapping timestamps, use whichever source has data.
Args:
source1: First OHLCV source (validated).
source2: Second OHLCV source (validated).
tolerance: Maximum time difference for timestamp alignment.
prefer: Conflict resolution — 'higher_volume' or 'source1' or 'source2'.
Returns:
Merged OHLCV DataFrame with a 'source' column indicating provenance.
"""
s1 = source1.copy()
s2 = source2.copy()
# Find all unique timestamps
all_timestamps = s1.index.union(s2.index).sort_values()
only_in_s1 = s1.index.difference(s2.index)
only_in_s2 = s2.index.difference(s1.index)
in_both = s1.index.intersection(s2.index)
print(f"\n Timestamps only in source1: {len(only_in_s1)}")
print(f" Timestamps only in source2: {len(only_in_s2)}")
print(f" Timestamps in both: {len(in_both)}")
print(f" Total unique timestamps: {len(all_timestamps)}")
# Build merged DataFrame
ohlcv_cols = ["open", "high", "low", "close", "volume"]
merged = pd.DataFrame(index=all_timestamps, columns=ohlcv_cols + ["source"])
merged.index.name = "timestamp"
# Fill non-overlapping bars
if len(only_in_s1) > 0:
for col in ohlcv_cols:
merged.loc[only_in_s1, col] = s1.loc[only_in_s1, col]
merged.loc[only_in_s1, "source"] = "source1"
if len(only_in_s2) > 0:
for col in ohlcv_cols:
merged.loc[only_in_s2, col] = s2.loc[only_in_s2, col]
merged.loc[only_in_s2, "source"] = "source2"
# Resolve overlapping bars
if len(in_both) > 0:
if prefer == "higher_volume":
use_s2 = s2.loc[in_both, "volume"] > s1.loc[in_both, "volume"]
use_s1_idx = in_both[~use_s2]
use_s2_idx = in_both[use_s2]
for col in ohlcv_cols:
if len(use_s1_idx) > 0:
merged.loc[use_s1_idx, col] = s1.loc[use_s1_idx, col]
if len(use_s2_idx) > 0:
merged.loc[use_s2_idx, col] = s2.loc[use_s2_idx, col]
merged.loc[use_s1_idx, "source"] = "source1"
merged.loc[use_s2_idx, "source"] = "source2"
print(f" Conflicts resolved: {len(use_s1_idx)} → source1, "
f"{len(use_s2_idx)} → source2")
elif prefer == "source1":
for col in ohlcv_cols:
merged.loc[in_both, col] = s1.loc[in_both, col]
merged.loc[in_both, "source"] = "source1"
elif prefer == "source2":
for col in ohlcv_cols:
merged.loc[in_both, col] = s2.loc[in_both, col]
merged.loc[in_both, "source"] = "source2"
# Ensure numeric types
for col in ohlcv_cols:
merged[col] = pd.to_numeric(merged[col], errors="coerce")
return merged.sort_index()
# ── Quality Report ──────────────────────────────────────────────────
def merged_quality_report(merged: pd.DataFrame) -> None:
"""Print quality summary of the merged dataset.
Args:
merged: Merged OHLCV DataFrame with 'source' column.
"""
print("\n" + "=" * 60)
print(" MERGED DATASET QUALITY REPORT")
print("=" * 60)
total = len(merged)
print(f" Total bars: {total}")
print(f" Date range: {merged.index.min()} -> {merged.index.max()}")
# Source breakdown
source_counts = merged["source"].value_counts()
for source, count in source_counts.items():
pct = count / total * 100
print(f" From {source}: {count} ({pct:.1f}%)")
# Data quality
nan_count = merged[["open", "high", "low", "close"]].isna().any(axis=1).sum()
zero_vol = (merged["volume"] == 0).sum()
impossible = (merged["high"].astype(float) < merged["low"].astype(float)).sum()
print(f"\n Missing prices: {nan_count}")
print(f" Zero volume bars: {zero_vol}")
print(f" Impossible candles: {impossible}")
completeness = (1 - merged["close"].isna().mean()) * 100
print(f" Completeness: {completeness:.2f}%")
print("=" * 60)
# ── File I/O ────────────────────────────────────────────────────────
def load_csv(filepath: str) -> pd.DataFrame:
"""Load OHLCV data from a CSV file.
Expects columns: timestamp (or as index), open, high, low, close, volume.
Args:
filepath: Path to CSV file.
Returns:
OHLCV DataFrame with DatetimeIndex.
"""
df = pd.read_csv(filepath)
df.columns = df.columns.str.lower().str.strip()
if "timestamp" in df.columns:
df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df = df.set_index("timestamp")
elif "date" in df.columns:
df["date"] = pd.to_datetime(df["date"], utc=True)
df = df.set_index("date")
df.index.name = "timestamp"
else:
df.index = pd.to_datetime(df.index, utc=True)
df.index.name = "timestamp"
return df
# ── CLI ─────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments.
Returns:
Parsed arguments namespace.
"""
parser = argparse.ArgumentParser(
description="Merge OHLCV data from two sources"
)
parser.add_argument(
"--demo", action="store_true",
help="Run with synthetic demo data"
)
parser.add_argument(
"--source1", type=str, default=None,
help="Path to first source CSV file"
)
parser.add_argument(
"--source2", type=str, default=None,
help="Path to second source CSV file"
)
parser.add_argument(
"--prefer", type=str, default="higher_volume",
choices=["higher_volume", "source1", "source2"],
help="Conflict resolution strategy (default: higher_volume)"
)
parser.add_argument(
"--tolerance", type=str, default=DEFAULT_TOLERANCE,
help=f"Timestamp alignment tolerance (default: {DEFAULT_TOLERANCE})"
)
return parser.parse_args()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
args = parse_args()
if args.demo:
print("Running in DEMO mode with synthetic data...\n")
s1, s2 = generate_source_pair()
elif args.source1 and args.source2:
print(f"Loading source 1: {args.source1}")
s1 = load_csv(args.source1)
print(f"Loading source 2: {args.source2}")
s2 = load_csv(args.source2)
else:
print("Error: Provide --demo or --source1 and --source2 CSV paths")
print("Run with --help for usage information.")
sys.exit(1)
# Validate both sources
print("\nValidating sources...")
s1 = validate_source(s1, "source1")
s2 = validate_source(s2, "source2")
# Analyze discrepancies
print("\nAnalyzing discrepancies...")
disc = analyze_discrepancies(s1, s2, "source1", "source2")
print_discrepancy_report(disc, "source1", "source2")
# Merge
print("\nMerging sources...")
merged = merge_ohlcv_sources(s1, s2, tolerance=args.tolerance, prefer=args.prefer)
# Report
merged_quality_report(merged)
# Preview
print(f"\nFirst 3 bars of merged data:")
print(merged[["open", "high", "low", "close", "volume", "source"]].head(3).to_string())
print(f"\nLast 3 bars of merged data:")
print(merged[["open", "high", "low", "close", "volume", "source"]].tail(3).to_string())
#!/usr/bin/env python3
"""Full OHLCV processing pipeline: validate, clean, resample, normalize.
Runs a complete data preparation workflow on OHLCV candle data. Supports
fetching from the Birdeye API or running in --demo mode with synthetic data
that includes intentional anomalies for testing.
Usage:
python scripts/process_ohlcv.py --demo
python scripts/process_ohlcv.py --token So11111111111111111111111111111111111111112
Dependencies:
uv pip install pandas numpy httpx
Environment Variables:
BIRDEYE_API_KEY: Your Birdeye API key (only needed for live data mode)
"""
import argparse
import os
import sys
from typing import Optional
import numpy as np
import pandas as pd
# ── Configuration ───────────────────────────────────────────────────
BIRDEYE_BASE = "https://public-api.birdeye.so"
REQUIRED_COLUMNS = {"open", "high", "low", "close", "volume"}
OHLCV_RESAMPLE_RULES: dict[str, str] = {
"open": "first",
"high": "max",
"low": "min",
"close": "last",
"volume": "sum",
}
TIMEFRAME_LADDER = ["1min", "5min", "15min", "1h", "4h", "1D"]
# ── Demo Data Generation ───────────────────────────────────────────
def generate_demo_data(bars: int = 1440, seed: int = 42) -> pd.DataFrame:
"""Generate synthetic 1-minute OHLCV data with intentional anomalies.
Creates ~24 hours of data with realistic price action and injects:
- Price spikes (3 bars)
- Zero volume bars (5 bars)
- Impossible candles where high < low (2 bars)
- Negative prices (1 bar)
- Duplicate timestamps (2 bars)
- Gaps (10 missing bars)
Args:
bars: Number of bars to generate.
seed: Random seed for reproducibility.
Returns:
Raw OHLCV DataFrame with anomalies (before cleaning).
"""
rng = np.random.default_rng(seed)
timestamps = pd.date_range(
start="2025-06-01 00:00:00", periods=bars, freq="1min", tz="UTC"
)
# Generate a random walk for close prices starting at $100
returns = rng.normal(0.0001, 0.003, size=bars)
close = 100.0 * np.cumprod(1 + returns)
# Derive OHLC from close
spread = rng.uniform(0.001, 0.005, size=bars)
high = close * (1 + spread)
low = close * (1 - spread)
open_price = close * (1 + rng.normal(0, 0.002, size=bars))
# Ensure OHLC consistency before injecting anomalies
high = np.maximum(high, np.maximum(open_price, close))
low = np.minimum(low, np.minimum(open_price, close))
volume = rng.exponential(50000, size=bars).astype(float)
df = pd.DataFrame(
{
"open": open_price,
"high": high,
"low": low,
"close": close,
"volume": volume,
},
index=timestamps,
)
df.index.name = "timestamp"
# ── Inject anomalies ────────────────────────────────────────────
# 1. Price spikes: 3 bars with 20x normal return
spike_indices = [200, 600, 1000]
for idx in spike_indices:
if idx < len(df):
df.iloc[idx, df.columns.get_loc("close")] *= 1.5
df.iloc[idx, df.columns.get_loc("high")] *= 1.8
# 2. Zero volume bars
zero_vol_indices = [100, 300, 500, 700, 900]
for idx in zero_vol_indices:
if idx < len(df):
df.iloc[idx, df.columns.get_loc("volume")] = 0.0
# 3. Impossible candles (high < low)
impossible_indices = [150, 850]
for idx in impossible_indices:
if idx < len(df):
h = df.iloc[idx]["high"]
l = df.iloc[idx]["low"]
df.iloc[idx, df.columns.get_loc("high")] = l * 0.99
df.iloc[idx, df.columns.get_loc("low")] = h * 1.01
# 4. Negative price
if len(df) > 400:
df.iloc[400, df.columns.get_loc("close")] = -1.0
# 5. Gaps: remove 10 bars (will be detected during processing)
gap_indices = list(range(250, 260))
df = df.drop(df.index[gap_indices])
# 6. Duplicate timestamps: copy bar 50 to create a duplicate
if len(df) > 50:
dupe_row = df.iloc[50:51].copy()
dupe_row["volume"] = dupe_row["volume"] * 0.5 # Different volume
df = pd.concat([df, dupe_row]).sort_index()
print(f"Generated {len(df)} bars of demo data with injected anomalies")
return df
# ── Data Fetching ───────────────────────────────────────────────────
def fetch_birdeye_ohlcv(
token_address: str,
interval: str = "1m",
limit: int = 1000,
) -> pd.DataFrame:
"""Fetch OHLCV data from Birdeye API.
Args:
token_address: Solana token mint address.
interval: Candle interval (1m, 5m, 15m, 1H, 4H, 1D).
limit: Maximum number of candles to fetch.
Returns:
OHLCV DataFrame with DatetimeIndex in UTC.
Raises:
SystemExit: If API key is not set.
httpx.HTTPStatusError: On API error.
"""
import httpx
api_key = os.getenv("BIRDEYE_API_KEY", "")
if not api_key:
print("Error: Set BIRDEYE_API_KEY environment variable")
sys.exit(1)
headers = {
"X-API-KEY": api_key,
"x-chain": "solana",
"accept": "application/json",
}
import time
time_to = int(time.time())
# Estimate time_from based on interval and limit
interval_seconds = {
"1m": 60, "5m": 300, "15m": 900,
"1H": 3600, "4H": 14400, "1D": 86400,
}
seconds_per_bar = interval_seconds.get(interval, 60)
time_from = time_to - (limit * seconds_per_bar)
resp = httpx.get(
f"{BIRDEYE_BASE}/defi/ohlcv",
headers=headers,
params={
"address": token_address,
"type": interval,
"time_from": time_from,
"time_to": time_to,
},
timeout=30.0,
)
resp.raise_for_status()
data = resp.json()
items = data.get("data", {}).get("items", [])
if not items:
print("Warning: No OHLCV data returned from Birdeye")
return pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
df = pd.DataFrame(items)
df["timestamp"] = pd.to_datetime(df["unixTime"], unit="s", utc=True)
df = df.set_index("timestamp")
df = df.rename(columns={"o": "open", "h": "high", "l": "low", "c": "close", "v": "volume"})
df = df[["open", "high", "low", "close", "volume"]]
print(f"Fetched {len(df)} bars from Birdeye API")
return df
# ── Validation ──────────────────────────────────────────────────────
def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
"""Normalize column names to lowercase standard format.
Args:
df: Raw OHLCV DataFrame.
Returns:
DataFrame with standardized column names.
Raises:
ValueError: If required columns are missing after normalization.
"""
df = df.copy()
df.columns = df.columns.str.lower().str.strip()
rename_map = {"vol": "volume", "v": "volume", "o": "open",
"h": "high", "l": "low", "c": "close"}
df = df.rename(columns=rename_map)
missing = REQUIRED_COLUMNS - set(df.columns)
if missing:
raise ValueError(f"Missing required columns: {missing}")
return df[["open", "high", "low", "close", "volume"]]
def validate_structure(df: pd.DataFrame) -> pd.DataFrame:
"""Validate and fix OHLCV structural integrity.
- Ensures DatetimeIndex in UTC
- Sorts by timestamp ascending
- Removes duplicate timestamps (keeps last)
- Enforces numeric types
Args:
df: OHLCV DataFrame.
Returns:
Structurally valid DataFrame.
"""
df = df.copy()
# Ensure DatetimeIndex
if not isinstance(df.index, pd.DatetimeIndex):
df.index = pd.to_datetime(df.index, utc=True)
if df.index.tz is None:
df.index = df.index.tz_localize("UTC")
elif str(df.index.tz) != "UTC":
df.index = df.index.tz_convert("UTC")
# Sort ascending
df = df.sort_index()
# Remove duplicates
dupes = df.index.duplicated(keep="last")
if dupes.any():
n_dupes = dupes.sum()
print(f" Removed {n_dupes} duplicate timestamp(s)")
df = df[~dupes]
# Enforce numeric types
for col in ["open", "high", "low", "close", "volume"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
return df
# ── Anomaly Detection ──────────────────────────────────────────────
def detect_anomalies(
df: pd.DataFrame,
spike_window: int = 20,
spike_threshold: float = 3.0,
) -> pd.DataFrame:
"""Detect and flag all anomaly types in OHLCV data.
Checks for:
- Price spikes (return > threshold * rolling std)
- Zero volume bars
- Impossible candles (high < low, etc.)
- Negative prices
- NaN values
Args:
df: Validated OHLCV DataFrame.
spike_window: Rolling window for spike detection.
spike_threshold: Number of standard deviations for spike threshold.
Returns:
DataFrame with anomaly flag columns added.
"""
df = df.copy()
# Price spikes
returns = df["close"].pct_change()
rolling_std = returns.rolling(spike_window, min_periods=5).std()
df["anomaly_spike"] = (returns.abs() > (spike_threshold * rolling_std)).fillna(False)
# Zero volume
df["anomaly_zero_vol"] = df["volume"] <= 0
# Impossible candles
df["anomaly_impossible"] = (
(df["high"] < df["low"]) |
(df["high"] < df["open"]) |
(df["high"] < df["close"]) |
(df["low"] > df["open"]) |
(df["low"] > df["close"])
)
# Negative prices
df["anomaly_negative"] = (
(df["open"] < 0) | (df["high"] < 0) |
(df["low"] < 0) | (df["close"] < 0)
)
# NaN values
df["anomaly_nan"] = df[["open", "high", "low", "close"]].isna().any(axis=1)
# Composite flag
df["anomaly_any"] = (
df["anomaly_spike"] | df["anomaly_zero_vol"] |
df["anomaly_impossible"] | df["anomaly_negative"] |
df["anomaly_nan"]
)
return df
# ── Cleaning ────────────────────────────────────────────────────────
def clean_anomalies(df: pd.DataFrame) -> pd.DataFrame:
"""Fix detected anomalies in OHLCV data.
- Removes bars with negative prices
- Fixes impossible candles by recalculating high/low
- Interpolates price spikes
- Leaves zero-volume bars flagged but intact
Args:
df: DataFrame with anomaly flags from detect_anomalies().
Returns:
Cleaned DataFrame.
"""
df = df.copy()
initial_len = len(df)
# Remove negative prices
neg_mask = df.get("anomaly_negative", pd.Series(False, index=df.index))
if neg_mask.any():
n_neg = neg_mask.sum()
df = df[~neg_mask]
print(f" Removed {n_neg} bar(s) with negative prices")
# Fix impossible candles
impossible = (
(df["high"] < df["low"]) |
(df["high"] < df["open"]) |
(df["high"] < df["close"]) |
(df["low"] > df["open"]) |
(df["low"] > df["close"])
)
if impossible.any():
n_imp = impossible.sum()
# Recalculate high and low from all four price fields
price_cols = df.loc[impossible, ["open", "high", "low", "close"]]
df.loc[impossible, "high"] = price_cols.max(axis=1)
df.loc[impossible, "low"] = price_cols.min(axis=1)
print(f" Fixed {n_imp} impossible candle(s)")
# Interpolate price spikes
spike_mask = df.get("anomaly_spike", pd.Series(False, index=df.index))
if spike_mask.any():
n_spikes = spike_mask.sum()
for col in ["open", "high", "low", "close"]:
df.loc[spike_mask, col] = np.nan
df[col] = df[col].interpolate(method="time")
print(f" Interpolated {n_spikes} price spike(s)")
removed = initial_len - len(df)
if removed > 0:
print(f" Total bars removed: {removed}")
return df
# ── Gap Handling ────────────────────────────────────────────────────
def handle_gaps(
df: pd.DataFrame,
freq: str = "1min",
method: str = "ffill",
max_gap: int = 5,
) -> pd.DataFrame:
"""Detect and fill gaps in OHLCV data.
Args:
df: OHLCV DataFrame.
freq: Expected bar frequency.
method: Fill method ('ffill' or 'interpolate').
max_gap: Maximum consecutive bars to fill.
Returns:
DataFrame with gaps filled and is_filled column added.
"""
df = df.copy()
full_index = pd.date_range(
start=df.index.min(), end=df.index.max(), freq=freq, tz="UTC"
)
n_missing = len(full_index) - len(df)
df = df.reindex(full_index)
df.index.name = "timestamp"
df["is_filled"] = df["close"].isna()
if n_missing > 0:
print(f" Found {n_missing} gap(s) in data")
if method == "ffill":
for col in ["open", "high", "low", "close"]:
df[col] = df[col].ffill(limit=max_gap)
df["volume"] = df["volume"].fillna(0)
elif method == "interpolate":
for col in ["open", "high", "low", "close"]:
df[col] = df[col].interpolate(method="time", limit=max_gap)
df["volume"] = df["volume"].fillna(0)
filled_count = df["is_filled"].sum()
still_nan = df["close"].isna().sum()
if filled_count > 0:
print(f" Filled {int(filled_count - still_nan)} bar(s), "
f"{int(still_nan)} unfillable (gap > {max_gap})")
return df
# ── Resampling ──────────────────────────────────────────────────────
def resample_ohlcv(df: pd.DataFrame, target_freq: str) -> pd.DataFrame:
"""Resample OHLCV data to a coarser timeframe.
Args:
df: OHLCV DataFrame (must be finer than target_freq).
target_freq: Pandas frequency string (e.g., '5min', '1h', '1D').
Returns:
Resampled OHLCV DataFrame.
"""
ohlcv_cols = [c for c in ["open", "high", "low", "close", "volume"] if c in df.columns]
resampled = df[ohlcv_cols].resample(target_freq).agg(OHLCV_RESAMPLE_RULES)
return resampled.dropna(subset=["close"])
def resample_ladder(df: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""Resample 1-minute data to all standard timeframes.
Args:
df: 1-minute OHLCV DataFrame.
Returns:
Dictionary mapping timeframe labels to resampled DataFrames.
"""
results: dict[str, pd.DataFrame] = {"1min": df.copy()}
for tf in TIMEFRAME_LADDER[1:]:
results[tf] = resample_ohlcv(df, tf)
return results
# ── Normalization ───────────────────────────────────────────────────
def normalize_prices(
df: pd.DataFrame, method: str = "returns"
) -> pd.DataFrame:
"""Normalize OHLCV price columns.
Args:
df: OHLCV DataFrame.
method: Normalization method — 'returns', 'log_returns', 'minmax', 'zscore'.
Returns:
DataFrame with normalized columns added (original columns preserved).
"""
price_cols = ["open", "high", "low", "close"]
result = df.copy()
if method == "returns":
for col in price_cols:
result[f"{col}_ret"] = result[col].pct_change()
elif method == "log_returns":
for col in price_cols:
result[f"{col}_logret"] = np.log(result[col] / result[col].shift(1))
elif method == "minmax":
for col in price_cols:
cmin = result[col].min()
cmax = result[col].max()
denom = cmax - cmin
if denom == 0:
result[f"{col}_norm"] = 0.0
else:
result[f"{col}_norm"] = (result[col] - cmin) / denom
elif method == "zscore":
for col in price_cols:
std = result[col].std()
if std == 0:
result[f"{col}_z"] = 0.0
else:
result[f"{col}_z"] = (result[col] - result[col].mean()) / std
else:
raise ValueError(f"Unknown normalization method: {method}")
return result
# ── Quality Report ──────────────────────────────────────────────────
def quality_report(df: pd.DataFrame) -> dict[str, object]:
"""Generate a comprehensive data quality summary.
Args:
df: OHLCV DataFrame (may include anomaly flag columns).
Returns:
Dictionary with quality metrics.
"""
total = len(df)
report: dict[str, object] = {
"total_bars": total,
"date_range": f"{df.index.min()} -> {df.index.max()}",
"missing_close": int(df["close"].isna().sum()),
"missing_any_price": int(df[["open", "high", "low", "close"]].isna().any(axis=1).sum()),
"zero_volume_bars": int((df["volume"] == 0).sum()),
"impossible_candles": int((df["high"] < df["low"]).sum()),
"negative_prices": int((df[["open", "high", "low", "close"]] < 0).any(axis=1).sum()),
"duplicate_timestamps": int(df.index.duplicated().sum()),
}
if "anomaly_spike" in df.columns:
report["flagged_spikes"] = int(df["anomaly_spike"].sum())
if "anomaly_any" in df.columns:
report["total_anomalies"] = int(df["anomaly_any"].sum())
if "is_filled" in df.columns:
report["filled_bars"] = int(df["is_filled"].sum())
# Completeness
completeness = (1 - df["close"].isna().mean()) * 100
report["completeness_pct"] = round(completeness, 2)
return report
def print_report(report: dict[str, object]) -> None:
"""Pretty-print a quality report.
Args:
report: Dictionary from quality_report().
"""
print("\n" + "=" * 60)
print(" OHLCV DATA QUALITY REPORT")
print("=" * 60)
for key, value in report.items():
label = key.replace("_", " ").title()
print(f" {label:.<40} {value}")
print("=" * 60 + "\n")
# ── Full Pipeline ───────────────────────────────────────────────────
def run_pipeline(
df: pd.DataFrame,
freq: str = "1min",
gap_method: str = "ffill",
max_gap: int = 5,
normalize: str = "returns",
resample_to: Optional[list[str]] = None,
) -> dict[str, pd.DataFrame]:
"""Run the complete OHLCV processing pipeline.
Steps: standardize -> validate -> detect anomalies -> report (pre-clean)
-> clean -> fill gaps -> normalize -> resample -> report (post-clean)
Args:
df: Raw OHLCV DataFrame.
freq: Expected bar frequency for gap detection.
gap_method: Gap fill method ('ffill' or 'interpolate').
max_gap: Maximum consecutive gap bars to fill.
normalize: Normalization method.
resample_to: List of target timeframes (e.g., ['5min', '1h']).
Returns:
Dictionary with 'clean' DataFrame and any resampled DataFrames.
"""
print("\n[1/6] Standardizing columns...")
df = standardize_columns(df)
print("[2/6] Validating structure...")
df = validate_structure(df)
print("[3/6] Detecting anomalies...")
df = detect_anomalies(df)
pre_report = quality_report(df)
print_report(pre_report)
print("[4/6] Cleaning anomalies...")
df = clean_anomalies(df)
print("[5/6] Handling gaps...")
df = handle_gaps(df, freq=freq, method=gap_method, max_gap=max_gap)
print("[6/6] Normalizing prices...")
df = normalize_prices(df, method=normalize)
# Re-run anomaly detection on cleaned data for final report
df = detect_anomalies(df)
post_report = quality_report(df)
print("\n--- Post-Processing Report ---")
print_report(post_report)
results: dict[str, pd.DataFrame] = {"clean": df}
# Resample if requested
if resample_to:
print("Resampling to additional timeframes...")
ohlcv_cols = ["open", "high", "low", "close", "volume"]
base = df[ohlcv_cols].dropna(subset=["close"])
for tf in resample_to:
resampled = resample_ohlcv(base, tf)
results[tf] = resampled
print(f" {tf}: {len(resampled)} bars")
return results
# ── CLI ─────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments.
Returns:
Parsed arguments namespace.
"""
parser = argparse.ArgumentParser(
description="OHLCV data processing pipeline"
)
parser.add_argument(
"--demo", action="store_true",
help="Run with synthetic demo data (no API key needed)"
)
parser.add_argument(
"--token", type=str, default=None,
help="Solana token mint address to fetch from Birdeye"
)
parser.add_argument(
"--freq", type=str, default="1min",
help="Expected bar frequency (default: 1min)"
)
parser.add_argument(
"--gap-method", type=str, default="ffill",
choices=["ffill", "interpolate"],
help="Gap fill method (default: ffill)"
)
parser.add_argument(
"--max-gap", type=int, default=5,
help="Maximum consecutive gap bars to fill (default: 5)"
)
parser.add_argument(
"--normalize", type=str, default="returns",
choices=["returns", "log_returns", "minmax", "zscore"],
help="Normalization method (default: returns)"
)
parser.add_argument(
"--resample", type=str, nargs="*", default=["5min", "15min", "1h"],
help="Target timeframes for resampling (default: 5min 15min 1h)"
)
return parser.parse_args()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
args = parse_args()
if args.demo:
print("Running in DEMO mode with synthetic data...\n")
raw_df = generate_demo_data()
elif args.token:
raw_df = fetch_birdeye_ohlcv(args.token)
else:
print("Error: Provide --demo or --token <address>")
print("Run with --help for usage information.")
sys.exit(1)
results = run_pipeline(
raw_df,
freq=args.freq,
gap_method=args.gap_method,
max_gap=args.max_gap,
normalize=args.normalize,
resample_to=args.resample,
)
clean_df = results["clean"]
print(f"\nFinal clean dataset: {len(clean_df)} bars")
print(f"Columns: {list(clean_df.columns)}")
print(f"\nFirst 3 bars:")
print(clean_df[["open", "high", "low", "close", "volume"]].head(3).to_string())
print(f"\nLast 3 bars:")
print(clean_df[["open", "high", "low", "close", "volume"]].tail(3).to_string())
Related skills
FAQ
What column format does it expect?
A DatetimeIndex in UTC with lowercase open, high, low, close, volume columns, sorted ascending with no duplicate timestamps.
How does it fill gaps?
It reindexes to the expected frequency and either forward-fills up to a max gap (flat candle, zero volume) or interpolates, marking filled bars with an is_filled flag.