Files
foxhunt/crates/ml/src/fxcache.rs
jgrusewski a4ba8bddaa feat: fxcache stores per-bar timestamps for walk-forward windowing
Each record now starts with an i64 timestamp (nanoseconds since epoch)
before the feature/target/OFI data. v1 records grow from 432 to 440
bytes, v2 from 112 to 120 bytes. The timestamp is always i64 even in
bf16 mode. train_baseline_rl reconstructs bars with real timestamps
instead of placeholders so the walk-forward windower can split by month.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:29:41 +02:00

530 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! FxCache — Flat Binary Feature Cache for Zero-Overhead GPU Loading
//!
//! Provides a compact binary format for pre-computed DQN training data
//! (features + targets + OFI vectors) designed for direct GPU upload
//! without parsing overhead.
//!
//! ## Format
//!
//! ```text
//! ┌───────────────────────────────────────────────────┐
//! │ FxCacheHeader (64 bytes) │
//! │ magic [u8; 8] = b"FXCACHE\0" │
//! │ version u16 = 1 (f64) | 2 (bf16) │
//! │ feat_dim u16 = 42 │
//! │ target_dim u16 = 4 │
//! │ ofi_dim u16 = 8 │
//! │ bar_count u64 │
//! │ cache_key [u8; 32] (SHA256 raw bytes) │
//! │ reserved [u8; 8] │
//! └───────────────────────────────────────────────────┘
//! │ Body (bar_count records) │
//! │ Each record starts with an i64 timestamp (ns). │
//! │ Version 1: [i64 ts][54 × f64] = 440 bytes/bar │
//! │ Version 2: [i64 ts][56 × bf16] = 120 bytes/bar │
//! │ (54 data + 2 zero-padding) │
//! └───────────────────────────────────────────────────┘
//! ```
use anyhow::{bail, Context, Result};
use half::f16;
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use tracing::{debug, info};
// ── Constants ────────────────────────────────────────────────────────────────
/// Magic bytes identifying an FxCache file.
const FXCACHE_MAGIC: [u8; 8] = *b"FXCACHE\0";
/// Header size in bytes (fixed).
const HEADER_SIZE: usize = 64;
/// Feature vector dimensionality.
const FEAT_DIM: usize = 42;
/// Target vector dimensionality (close, next_close, raw_close, raw_next).
const TARGET_DIM: usize = 4;
/// OFI vector dimensionality (8 MBP-10 order-flow imbalance levels).
const OFI_DIM: usize = 8;
/// Total f64 values per record: features + targets + OFI = 42 + 4 + 8 = 54.
const RECORD_F64_COUNT: usize = FEAT_DIM + TARGET_DIM + OFI_DIM;
/// bf16 record width including 2 zero-padding values for 4-byte alignment.
const RECORD_BF16_COUNT: usize = RECORD_F64_COUNT + 2;
// ── Header ───────────────────────────────────────────────────────────────────
/// 64-byte fixed header for `.fxcache` files.
#[derive(Debug, Clone)]
pub struct FxCacheHeader {
/// Magic bytes: `b"FXCACHE\0"`.
pub magic: [u8; 8],
/// Format version: 1 = f64, 2 = bf16.
pub version: u16,
/// Feature dimension (42).
pub feat_dim: u16,
/// Target dimension (4).
pub target_dim: u16,
/// OFI dimension (8).
pub ofi_dim: u16,
/// Number of bars (records) in the file.
pub bar_count: u64,
/// SHA256 cache key (raw 32 bytes).
pub cache_key: [u8; 32],
/// Reserved for future use.
pub reserved: [u8; 8],
}
impl FxCacheHeader {
/// Create a new header with the given parameters.
pub fn new(version: u16, bar_count: u64, cache_key: [u8; 32]) -> Self {
Self {
magic: FXCACHE_MAGIC,
version,
feat_dim: FEAT_DIM as u16,
target_dim: TARGET_DIM as u16,
ofi_dim: OFI_DIM as u16,
bar_count,
cache_key,
reserved: [0u8; 8],
}
}
/// Validate header integrity.
pub fn validate(&self) -> Result<()> {
if self.magic != FXCACHE_MAGIC {
bail!(
"Invalid FxCache magic: expected {:?}, got {:?}",
FXCACHE_MAGIC,
self.magic
);
}
if self.version != 1 && self.version != 2 {
bail!(
"Unsupported FxCache version: {} (expected 1 or 2)",
self.version
);
}
if self.feat_dim as usize != FEAT_DIM {
bail!(
"Feature dimension mismatch: expected {}, got {}",
FEAT_DIM,
self.feat_dim
);
}
if self.target_dim as usize != TARGET_DIM {
bail!(
"Target dimension mismatch: expected {}, got {}",
TARGET_DIM,
self.target_dim
);
}
if self.ofi_dim as usize != OFI_DIM {
bail!(
"OFI dimension mismatch: expected {}, got {}",
OFI_DIM,
self.ofi_dim
);
}
if self.bar_count == 0 {
bail!("FxCache bar_count is zero — empty cache files are not valid");
}
Ok(())
}
/// Serialize header to 64 bytes (little-endian).
fn to_bytes(&self) -> [u8; HEADER_SIZE] {
let mut buf = [0u8; HEADER_SIZE];
buf[0..8].copy_from_slice(&self.magic);
buf[8..10].copy_from_slice(&self.version.to_le_bytes());
buf[10..12].copy_from_slice(&self.feat_dim.to_le_bytes());
buf[12..14].copy_from_slice(&self.target_dim.to_le_bytes());
buf[14..16].copy_from_slice(&self.ofi_dim.to_le_bytes());
buf[16..24].copy_from_slice(&self.bar_count.to_le_bytes());
buf[24..56].copy_from_slice(&self.cache_key);
buf[56..64].copy_from_slice(&self.reserved);
buf
}
/// Deserialize header from 64 bytes (little-endian).
fn from_bytes(buf: &[u8; HEADER_SIZE]) -> Self {
let mut magic = [0u8; 8];
magic.copy_from_slice(&buf[0..8]);
let version = u16::from_le_bytes([buf[8], buf[9]]);
let feat_dim = u16::from_le_bytes([buf[10], buf[11]]);
let target_dim = u16::from_le_bytes([buf[12], buf[13]]);
let ofi_dim = u16::from_le_bytes([buf[14], buf[15]]);
let bar_count = u64::from_le_bytes([
buf[16], buf[17], buf[18], buf[19], buf[20], buf[21], buf[22], buf[23],
]);
let mut cache_key = [0u8; 32];
cache_key.copy_from_slice(&buf[24..56]);
let mut reserved = [0u8; 8];
reserved.copy_from_slice(&buf[56..64]);
Self {
magic,
version,
feat_dim,
target_dim,
ofi_dim,
bar_count,
cache_key,
reserved,
}
}
}
// ── Data ─────────────────────────────────────────────────────────────────────
/// In-memory representation of an FxCache file's contents.
#[derive(Debug)]
pub struct FxCacheData {
/// Per-bar timestamps (nanoseconds since Unix epoch).
pub timestamps: Vec<i64>,
/// Feature vectors, one per bar (42 elements each).
pub features: Vec<[f64; FEAT_DIM]>,
/// Target vectors, one per bar (4 elements each).
pub targets: Vec<[f64; TARGET_DIM]>,
/// OFI vectors, one per bar (8 elements each).
pub ofi: Vec<[f64; OFI_DIM]>,
/// SHA256 cache key (raw 32 bytes).
pub cache_key: [u8; 32],
/// Number of bars.
pub bar_count: usize,
}
// ── Writer ───────────────────────────────────────────────────────────────────
/// Write feature/target/OFI data to an `.fxcache` binary file.
///
/// # Arguments
///
/// * `path` — Output file path (parent directories are created automatically)
/// * `features` — Slice of 42-element feature vectors
/// * `targets` — Slice of 4-element target vectors
/// * `ofi` — Slice of 8-element OFI vectors
/// * `timestamps` — Per-bar timestamps (nanoseconds since Unix epoch)
/// * `cache_key` — SHA256 key (raw 32 bytes)
/// * `bf16` — If true, write version 2 (bf16); otherwise version 1 (f64)
///
/// # Returns
///
/// Total bytes written (header + body).
pub fn write_fxcache(
path: &Path,
features: &[[f64; FEAT_DIM]],
targets: &[[f64; TARGET_DIM]],
ofi: &[[f64; OFI_DIM]],
timestamps: &[i64],
cache_key: [u8; 32],
bf16: bool,
) -> Result<u64> {
let bar_count = features.len();
if targets.len() != bar_count || ofi.len() != bar_count || timestamps.len() != bar_count {
bail!(
"Length mismatch: features={}, targets={}, ofi={}, timestamps={}",
bar_count,
targets.len(),
ofi.len(),
timestamps.len()
);
}
if bar_count == 0 {
bail!("Cannot write empty FxCache (0 bars)");
}
// Create parent directories
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent dirs for {:?}", path))?;
}
let version: u16 = if bf16 { 2 } else { 1 };
let header = FxCacheHeader::new(version, bar_count as u64, cache_key);
header.validate()?;
let file = std::fs::File::create(path)
.with_context(|| format!("Failed to create FxCache file {:?}", path))?;
let mut writer = BufWriter::new(file);
// Write header
writer
.write_all(&header.to_bytes())
.context("Failed to write FxCache header")?;
// Write body
let body_bytes: u64 = if bf16 {
write_body_bf16(&mut writer, features, targets, ofi, timestamps)?
} else {
write_body_f64(&mut writer, features, targets, ofi, timestamps)?
};
writer.flush().context("Failed to flush FxCache writer")?;
let total_bytes = HEADER_SIZE as u64 + body_bytes;
info!(
"FxCache written: {} bars, v{} ({}), {:.2} MB -> {:?}",
bar_count,
version,
if bf16 { "bf16" } else { "f64" },
total_bytes as f64 / 1_048_576.0,
path
);
Ok(total_bytes)
}
/// Write body in f64 format (version 1): [i64 ts] + 54 f64 values = 440 bytes per bar.
fn write_body_f64(
writer: &mut BufWriter<std::fs::File>,
features: &[[f64; FEAT_DIM]],
targets: &[[f64; TARGET_DIM]],
ofi: &[[f64; OFI_DIM]],
timestamps: &[i64],
) -> Result<u64> {
let bytes_per_bar = 8 + RECORD_F64_COUNT * 8; // i64 timestamp + f64 data
let total = features.len() as u64 * bytes_per_bar as u64;
for i in 0..features.len() {
writer.write_all(&timestamps[i].to_le_bytes())?;
for &v in &features[i] {
writer.write_all(&v.to_le_bytes())?;
}
for &v in &targets[i] {
writer.write_all(&v.to_le_bytes())?;
}
for &v in &ofi[i] {
writer.write_all(&v.to_le_bytes())?;
}
}
Ok(total)
}
/// Write body in bf16 format (version 2): [i64 ts] + 56 bf16 values = 120 bytes per bar.
/// (54 data values + 2 zero-padding for 4-byte alignment.)
/// Timestamp is always i64 (8 bytes) — nanosecond precision requires 64 bits.
fn write_body_bf16(
writer: &mut BufWriter<std::fs::File>,
features: &[[f64; FEAT_DIM]],
targets: &[[f64; TARGET_DIM]],
ofi: &[[f64; OFI_DIM]],
timestamps: &[i64],
) -> Result<u64> {
let bytes_per_bar = 8 + RECORD_BF16_COUNT * 2; // i64 timestamp + bf16 data
let total = features.len() as u64 * bytes_per_bar as u64;
let zero = f16::ZERO;
for i in 0..features.len() {
writer.write_all(&timestamps[i].to_le_bytes())?;
for &v in &features[i] {
writer.write_all(&f16::from_f64(v).to_le_bytes())?;
}
for &v in &targets[i] {
writer.write_all(&f16::from_f64(v).to_le_bytes())?;
}
for &v in &ofi[i] {
writer.write_all(&f16::from_f64(v).to_le_bytes())?;
}
// 2 zero-padding bf16 values for alignment
writer.write_all(&zero.to_le_bytes())?;
writer.write_all(&zero.to_le_bytes())?;
}
Ok(total)
}
// ── Reader ───────────────────────────────────────────────────────────────────
/// Load an `.fxcache` file into memory.
///
/// Reads the 64-byte header, validates it, then reads the body according to
/// the version (f64 or bf16). bf16 values are up-converted to f64 on load.
///
/// # Arguments
///
/// * `path` — Path to the `.fxcache` file
///
/// # Returns
///
/// Fully parsed `FxCacheData` with features, targets, OFI, cache key, and bar count.
pub fn load_fxcache(path: &Path) -> Result<FxCacheData> {
let file = std::fs::File::open(path)
.with_context(|| format!("Failed to open FxCache file {:?}", path))?;
let file_len = file
.metadata()
.with_context(|| format!("Failed to stat FxCache file {:?}", path))?
.len();
let mut reader = BufReader::new(file);
// Read header
let mut header_buf = [0u8; HEADER_SIZE];
reader
.read_exact(&mut header_buf)
.context("Failed to read FxCache header")?;
let header = FxCacheHeader::from_bytes(&header_buf);
header.validate()?;
let bar_count = header.bar_count as usize;
// Sanity-check file size (each record has an i64 timestamp prefix)
let expected_body = if header.version == 1 {
bar_count as u64 * (8 + RECORD_F64_COUNT as u64 * 8)
} else {
bar_count as u64 * (8 + RECORD_BF16_COUNT as u64 * 2)
};
let expected_total = HEADER_SIZE as u64 + expected_body;
if file_len < expected_total {
bail!(
"FxCache file truncated: expected {} bytes, got {}",
expected_total,
file_len
);
}
// Read body
let (timestamps, features, targets, ofi) = if header.version == 1 {
read_body_f64(&mut reader, bar_count)?
} else {
read_body_bf16(&mut reader, bar_count)?
};
info!(
"FxCache loaded: {} bars, v{} ({}) from {:?}",
bar_count,
header.version,
if header.version == 1 { "f64" } else { "bf16" },
path
);
Ok(FxCacheData {
timestamps,
features,
targets,
ofi,
cache_key: header.cache_key,
bar_count,
})
}
/// Read body in f64 format (version 1).
fn read_body_f64(
reader: &mut BufReader<std::fs::File>,
bar_count: usize,
) -> Result<(Vec<i64>, Vec<[f64; FEAT_DIM]>, Vec<[f64; TARGET_DIM]>, Vec<[f64; OFI_DIM]>)> {
let mut timestamps = Vec::with_capacity(bar_count);
let mut features = Vec::with_capacity(bar_count);
let mut targets = Vec::with_capacity(bar_count);
let mut ofi = Vec::with_capacity(bar_count);
let mut i64_buf = [0u8; 8];
let mut f64_buf = [0u8; 8];
for _ in 0..bar_count {
reader.read_exact(&mut i64_buf)?;
timestamps.push(i64::from_le_bytes(i64_buf));
let mut feat = [0.0_f64; FEAT_DIM];
for slot in &mut feat {
reader.read_exact(&mut f64_buf)?;
*slot = f64::from_le_bytes(f64_buf);
}
features.push(feat);
let mut tgt = [0.0_f64; TARGET_DIM];
for slot in &mut tgt {
reader.read_exact(&mut f64_buf)?;
*slot = f64::from_le_bytes(f64_buf);
}
targets.push(tgt);
let mut ofi_row = [0.0_f64; OFI_DIM];
for slot in &mut ofi_row {
reader.read_exact(&mut f64_buf)?;
*slot = f64::from_le_bytes(f64_buf);
}
ofi.push(ofi_row);
}
Ok((timestamps, features, targets, ofi))
}
/// Read body in bf16 format (version 2), converting to f64 on load.
/// Timestamp is always read as i64 (8 bytes) regardless of bf16 mode.
fn read_body_bf16(
reader: &mut BufReader<std::fs::File>,
bar_count: usize,
) -> Result<(Vec<i64>, Vec<[f64; FEAT_DIM]>, Vec<[f64; TARGET_DIM]>, Vec<[f64; OFI_DIM]>)> {
let mut timestamps = Vec::with_capacity(bar_count);
let mut features = Vec::with_capacity(bar_count);
let mut targets = Vec::with_capacity(bar_count);
let mut ofi = Vec::with_capacity(bar_count);
let mut i64_buf = [0u8; 8];
let mut bf16_buf = [0u8; 2];
for _ in 0..bar_count {
reader.read_exact(&mut i64_buf)?;
timestamps.push(i64::from_le_bytes(i64_buf));
let mut feat = [0.0_f64; FEAT_DIM];
for slot in &mut feat {
reader.read_exact(&mut bf16_buf)?;
*slot = f16::from_le_bytes(bf16_buf).to_f64();
}
features.push(feat);
let mut tgt = [0.0_f64; TARGET_DIM];
for slot in &mut tgt {
reader.read_exact(&mut bf16_buf)?;
*slot = f16::from_le_bytes(bf16_buf).to_f64();
}
targets.push(tgt);
let mut ofi_row = [0.0_f64; OFI_DIM];
for slot in &mut ofi_row {
reader.read_exact(&mut bf16_buf)?;
*slot = f16::from_le_bytes(bf16_buf).to_f64();
}
ofi.push(ofi_row);
// Skip 2 padding bf16 values
reader.read_exact(&mut bf16_buf)?;
reader.read_exact(&mut bf16_buf)?;
}
Ok((timestamps, features, targets, ofi))
}
// ── Finder ───────────────────────────────────────────────────────────────────
/// Find an `.fxcache` file by hex-encoded cache key in a cache directory.
///
/// Looks for a file named `<hex_key>.fxcache` in `cache_dir`.
///
/// # Arguments
///
/// * `cache_dir` — Directory to search
/// * `cache_key` — Raw 32-byte SHA256 cache key
///
/// # Returns
///
/// `Some(path)` if the file exists, `None` otherwise.
pub fn find_fxcache(cache_dir: &Path, cache_key: &[u8; 32]) -> Option<PathBuf> {
let hex_key = hex::encode(cache_key);
let candidate = cache_dir.join(format!("{hex_key}.fxcache"));
if candidate.exists() {
debug!("FxCache found: {:?}", candidate);
Some(candidate)
} else {
debug!("FxCache miss: {:?}", candidate);
None
}
}