diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..4e9e3e7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+ dotnet-quality: 'preview'
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --verbosity normal
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..e427d95
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,44 @@
+name: Publish NuGet Packages
+
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '10.0.x'
+ dotnet-quality: 'preview'
+
+ - name: Compute version
+ id: version
+ run: |
+ YEAR=$(date -u +%Y)
+ MONTH=$(date -u +%-m)
+ DAY=$(date -u +%-d)
+ VERSION="${YEAR}.${MONTH}.${DAY}.${GITHUB_RUN_NUMBER}"
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
+ echo "Computed version: ${VERSION}"
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release /p:Version=${{ steps.version.outputs.version }}
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --verbosity normal
+
+ - name: Pack
+ run: dotnet pack --no-build --configuration Release /p:Version=${{ steps.version.outputs.version }} --output ./nupkgs
+
+ - name: Push to NuGet
+ run: dotnet nuget push ./nupkgs/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d177d49
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,41 @@
+# .NET build
+bin/
+obj/
+*.csproj.user
+*.rsuser
+*.suo
+*.user
+.vs/
+
+# NuGet
+*.nupkg
+*.snupkg
+
+# C++ (cross-language)
+cross-language/cpp/build/
+
+# Node.js (cross-language)
+cross-language/typescript/node_modules/
+
+# Python
+__pycache__/
+*.pyc
+.venv/
+venv/
+
+# Test results & coverage
+TestResults/
+coverage/
+
+# Publish output
+**/publish/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# BenchmarkDotNet
+BenchmarkDotNet.Artifacts/
+
+# JetBrains
+.idea/
diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..29b194e
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,21 @@
+
+
+
+ 0.0.0-local
+ Daniel Bunting
+ Daniel Bunting
+ Apache-2.0
+ README.md
+ https://github.com/DanielBunting/Levels
+ git
+ https://github.com/DanielBunting/Levels
+ Levels - a multi-level time-series database for financial market data
+ timeseries;database;financial;market-data;orderbook;ohlcv
+ Copyright (c) Daniel Bunting
+
+
+
+
+
+
+
diff --git a/Levels.sln b/Levels.sln
new file mode 100644
index 0000000..347c22b
--- /dev/null
+++ b/Levels.sln
@@ -0,0 +1,328 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Core", "src\Levels.Core\Levels.Core.csproj", "{9752F8AC-BA53-430D-AB8E-CC5217356C4F}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Tests", "tests\Levels.Tests\Levels.Tests.csproj", "{4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Sinks", "src\Levels.Sinks\Levels.Sinks.csproj", "{1A03D818-3FA3-430D-8AAA-382DD36D45F7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.DataFlow", "src\Levels.DataFlow\Levels.DataFlow.csproj", "{52C67ED5-242A-4758-B529-14E0B8B84F5D}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Compaction", "src\Levels.Compaction\Levels.Compaction.csproj", "{1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Query", "src\Levels.Query\Levels.Query.csproj", "{58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Period", "src\Levels.Period\Levels.Period.csproj", "{18555F26-9DE7-4842-AAFB-8614061EB1E5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Resampled", "src\Levels.Resampled\Levels.Resampled.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.SourceGen", "src\Levels.SourceGen\Levels.SourceGen.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Benchmarks", "tests\Levels.Benchmarks\Levels.Benchmarks.csproj", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Hosting", "src\Levels.Hosting\Levels.Hosting.csproj", "{F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Cli", "src\Levels.Cli\Levels.Cli.csproj", "{081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Export", "src\Levels.Export\Levels.Export.csproj", "{0481DFA3-4AF9-4421-93E0-A3E3083FF003}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Hints", "src\Levels.Hints\Levels.Hints.csproj", "{05640B40-FB77-49E2-8741-D29F7ED13263}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Web", "src\Levels.Web\Levels.Web.csproj", "{5EF30C04-1383-44D8-A73A-E8D5FD67218D}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CryptoExchangeSample", "samples\CryptoExchangeSample\CryptoExchangeSample.csproj", "{4C888035-8687-4DE4-A192-FBE0BA7375F7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BinanceLiveSample", "samples\BinanceLiveSample\BinanceLiveSample.csproj", "{24DB7B77-A721-4DC4-926F-D23606FB65A3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Server", "src\Levels.Server\Levels.Server.csproj", "{0CF04D7C-C568-4539-ABCB-D1B54108D7D5}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Client", "src\Levels.Client\Levels.Client.csproj", "{07217170-E7D4-4DA4-AB08-BE52AA1D9174}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Levels.Protocol", "src\Levels.Protocol\Levels.Protocol.csproj", "{7EA25779-E02F-452C-AB8A-F5C5DCE972B2}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Debug|x64.Build.0 = Debug|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Debug|x86.Build.0 = Debug|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Release|x64.ActiveCfg = Release|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Release|x64.Build.0 = Release|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Release|x86.ActiveCfg = Release|Any CPU
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F}.Release|x86.Build.0 = Release|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Debug|x64.Build.0 = Debug|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Debug|x86.Build.0 = Debug|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Release|x64.ActiveCfg = Release|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Release|x64.Build.0 = Release|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Release|x86.ActiveCfg = Release|Any CPU
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113}.Release|x86.Build.0 = Release|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Debug|x64.Build.0 = Debug|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Debug|x86.Build.0 = Debug|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Release|x64.ActiveCfg = Release|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Release|x64.Build.0 = Release|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Release|x86.ActiveCfg = Release|Any CPU
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7}.Release|x86.Build.0 = Release|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Debug|x64.Build.0 = Debug|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Debug|x86.Build.0 = Debug|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Release|x64.ActiveCfg = Release|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Release|x64.Build.0 = Release|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Release|x86.ActiveCfg = Release|Any CPU
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D}.Release|x86.Build.0 = Release|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Debug|x64.Build.0 = Debug|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Debug|x86.Build.0 = Debug|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Release|x64.ActiveCfg = Release|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Release|x64.Build.0 = Release|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Release|x86.ActiveCfg = Release|Any CPU
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A}.Release|x86.Build.0 = Release|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Debug|x64.Build.0 = Debug|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Debug|x86.Build.0 = Debug|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Release|x64.ActiveCfg = Release|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Release|x64.Build.0 = Release|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Release|x86.ActiveCfg = Release|Any CPU
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD}.Release|x86.Build.0 = Release|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Debug|x64.Build.0 = Debug|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Debug|x86.Build.0 = Debug|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Release|x64.ActiveCfg = Release|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Release|x64.Build.0 = Release|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Release|x86.ActiveCfg = Release|Any CPU
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5}.Release|x86.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x86.Build.0 = Debug|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x86.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x64.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|x86.Build.0 = Debug|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x64.Build.0 = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.ActiveCfg = Release|Any CPU
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|x86.Build.0 = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x64.Build.0 = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Debug|x86.Build.0 = Debug|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.ActiveCfg = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x64.Build.0 = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.ActiveCfg = Release|Any CPU
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012}.Release|x86.Build.0 = Release|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Debug|x64.Build.0 = Debug|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Debug|x86.Build.0 = Debug|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Release|x64.ActiveCfg = Release|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Release|x64.Build.0 = Release|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Release|x86.ActiveCfg = Release|Any CPU
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC}.Release|x86.Build.0 = Release|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Debug|x64.Build.0 = Debug|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Debug|x86.Build.0 = Debug|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Release|x64.ActiveCfg = Release|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Release|x64.Build.0 = Release|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Release|x86.ActiveCfg = Release|Any CPU
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5}.Release|x86.Build.0 = Release|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Debug|x64.Build.0 = Debug|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Debug|x86.Build.0 = Debug|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Release|x64.ActiveCfg = Release|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Release|x64.Build.0 = Release|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Release|x86.ActiveCfg = Release|Any CPU
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003}.Release|x86.Build.0 = Release|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Debug|x64.Build.0 = Debug|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Debug|x86.Build.0 = Debug|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Release|Any CPU.Build.0 = Release|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Release|x64.ActiveCfg = Release|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Release|x64.Build.0 = Release|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Release|x86.ActiveCfg = Release|Any CPU
+ {05640B40-FB77-49E2-8741-D29F7ED13263}.Release|x86.Build.0 = Release|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Debug|x64.Build.0 = Debug|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Debug|x86.Build.0 = Debug|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Release|x64.ActiveCfg = Release|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Release|x64.Build.0 = Release|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Release|x86.ActiveCfg = Release|Any CPU
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D}.Release|x86.Build.0 = Release|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Debug|x64.Build.0 = Debug|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Debug|x86.Build.0 = Debug|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Release|x64.ActiveCfg = Release|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Release|x64.Build.0 = Release|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Release|x86.ActiveCfg = Release|Any CPU
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7}.Release|x86.Build.0 = Release|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Debug|x64.Build.0 = Debug|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Debug|x86.Build.0 = Debug|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Release|Any CPU.Build.0 = Release|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Release|x64.ActiveCfg = Release|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Release|x64.Build.0 = Release|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Release|x86.ActiveCfg = Release|Any CPU
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3}.Release|x86.Build.0 = Release|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Debug|x64.Build.0 = Debug|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Debug|x86.Build.0 = Debug|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Release|x64.ActiveCfg = Release|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Release|x64.Build.0 = Release|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Release|x86.ActiveCfg = Release|Any CPU
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5}.Release|x86.Build.0 = Release|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Debug|x64.Build.0 = Debug|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Debug|x86.Build.0 = Debug|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Release|Any CPU.Build.0 = Release|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Release|x64.ActiveCfg = Release|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Release|x64.Build.0 = Release|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Release|x86.ActiveCfg = Release|Any CPU
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174}.Release|x86.Build.0 = Release|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Debug|x64.Build.0 = Debug|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Debug|x86.Build.0 = Debug|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Release|x64.ActiveCfg = Release|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Release|x64.Build.0 = Release|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Release|x86.ActiveCfg = Release|Any CPU
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {9752F8AC-BA53-430D-AB8E-CC5217356C4F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {4EEFF4A6-6AF6-4D84-8333-8A9AF4263113} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {1A03D818-3FA3-430D-8AAA-382DD36D45F7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {52C67ED5-242A-4758-B529-14E0B8B84F5D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {1EA6F64E-7AD4-41EE-BBA9-4F0868294A6A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {58BCA5C9-578F-4FCC-9161-FF890E0EF9CD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {18555F26-9DE7-4842-AAFB-8614061EB1E5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {B2C3D4E5-F6A7-8901-BCDE-F12345678901} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {C3D4E5F6-A7B8-9012-CDEF-123456789012} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {F4BBAFE0-69B9-4890-9AC0-6D311137B6EC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {081623C1-3E9E-4276-ABC2-6C7C58E5DDF5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {0481DFA3-4AF9-4421-93E0-A3E3083FF003} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {05640B40-FB77-49E2-8741-D29F7ED13263} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {5EF30C04-1383-44D8-A73A-E8D5FD67218D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {4C888035-8687-4DE4-A192-FBE0BA7375F7} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {24DB7B77-A721-4DC4-926F-D23606FB65A3} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
+ {0CF04D7C-C568-4539-ABCB-D1B54108D7D5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {07217170-E7D4-4DA4-AB08-BE52AA1D9174} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {7EA25779-E02F-452C-AB8A-F5C5DCE972B2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e75288e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,100 @@
+# Levels
+
+A multi-level time-series database for financial market data -- orderbook events, price streaming, and OHLCV bars. Built on .NET 10.0 with cross-language support for C++, Python, and TypeScript.
+
+## Features
+
+- **Custom binary format** -- 56-byte core records with schema-extensible fields, CRC32 integrity, little-endian layout
+- **Three operational modes** -- embed in C# apps, run as a standalone TCP server, or deploy as a REST API
+- **Automatic data lifecycle** -- compaction, period promotion with data quality checks, configurable retention and archival
+- **Cross-language support** -- generate readers/writers for C#, Python, TypeScript, and C++ from a single `.fbs` schema
+- **Real-time and batch** -- OHLCV bar emission, top-of-book snapshots, orderbook L1/L2/L3 projection
+- **Export** -- CSV, Parquet, and Avro adapters
+- **Observability** -- OpenTelemetry metrics and tracing, Prometheus scraping endpoint
+
+## Prerequisites
+
+- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (preview)
+
+## Quick Start
+
+```bash
+# Build
+dotnet build
+
+# Run tests
+dotnet test
+
+# Run the sample (writes synthetic orderbook data)
+dotnet run --project samples/CryptoExchangeSample
+```
+
+See the [Quickstart Guide](docs/quickstart.md) for a full walkthrough.
+
+## Operational Modes
+
+| Mode | Use case | Entry point |
+|------|----------|-------------|
+| [Embedded](docs/embedded-mode.md) | In-process C# ingestion | `AddLevels()` + `AddLevelsDataSink()` |
+| [TCP Server](docs/server-mode.md) | Standalone daemon, multi-language clients | `src/Levels.Server` with YAML config |
+| [REST API](docs/web-api.md) | HTTP queries and export | `src/Levels.Web` with ASP.NET Core |
+
+## CLI
+
+The `levels` CLI tool provides file inspection, compaction, code generation, resampling, and data export.
+
+```bash
+dotnet run --project src/Levels.Cli -- --help
+```
+
+See the [CLI Reference](docs/cli.md) for all commands.
+
+## Documentation
+
+- [Quickstart](docs/quickstart.md) -- get up and running
+- [Architecture](docs/architecture.md) -- data pipeline, binary format, system internals
+- [Embedded Mode](docs/embedded-mode.md) -- in-process C# usage
+- [Server Mode](docs/server-mode.md) -- standalone TCP server
+- [Web API](docs/web-api.md) -- REST endpoints
+- [Schemas](docs/schemas.md) -- FlatBuffers schema system and code generation
+- [Configuration](docs/configuration.md) -- complete options reference
+- [CLI](docs/cli.md) -- command-line tool reference
+- [Cross-Language](docs/cross-language.md) -- C++, Python, TypeScript interop
+
+## Project Structure
+
+```
+Levels/
+ src/
+ Levels.Core/ Core binary format, interfaces, WAL, diagnostics
+ Levels.SourceGen/ Roslyn source generator for .fbs schemas (netstandard2.0)
+ Levels.Sinks/ Partitioned ingestion, circuit breaker, backpressure
+ Levels.DataFlow/ Channel-based async pub/sub bus
+ Levels.Query/ In-memory file index, orderbook projection
+ Levels.Compaction/ Event-sourcing replay, synthetic snapshots, archival
+ Levels.Period/ Data quality checks, promotion/demotion
+ Levels.Resampled/ OHLCV bars, top-of-book snapshots
+ Levels.Export/ CSV, Parquet, Avro adapters
+ Levels.Hosting/ DI registration (AddLevels), OpenTelemetry setup
+ Levels.Hints/ LiteDB-based query hints
+ Levels.Protocol/ Wire protocol (frame reader/writer)
+ Levels.Server/ Standalone TCP server
+ Levels.Client/ TCP client library
+ Levels.Web/ ASP.NET Core REST API
+ Levels.Cli/ Command-line tool
+ samples/
+ CryptoExchangeSample/ Simulated exchange (synthetic data)
+ BinanceLiveSample/ Live Binance WebSocket feed
+ tests/
+ Levels.Tests/ xUnit tests + cross-language round-trips
+ Levels.Benchmarks/ BenchmarkDotNet performance suite
+ cross-language/
+ schema.fbs Canonical schema definition
+ cpp/ C++ generated code and tests
+ python/ Python generated code and tests
+ typescript/ TypeScript generated code and tests
+```
+
+## License
+
+Apache-2.0
diff --git a/cross-language/cpp/CMakeLists.txt b/cross-language/cpp/CMakeLists.txt
new file mode 100644
index 0000000..9e8492f
--- /dev/null
+++ b/cross-language/cpp/CMakeLists.txt
@@ -0,0 +1,7 @@
+cmake_minimum_required(VERSION 3.14)
+project(aiondb_cross_language_cpp LANGUAGES CXX)
+
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+add_executable(write_test_file write_test_file.cpp)
diff --git a/cross-language/cpp/record.h b/cross-language/cpp/record.h
new file mode 100644
index 0000000..d87b0bf
--- /dev/null
+++ b/cross-language/cpp/record.h
@@ -0,0 +1,89 @@
+//
+// Generated from .fbs schema — do not edit by hand.
+
+#pragma once
+
+#include
+#include
+
+// ── Constants ──────────────────────────────────────────────────────────────────
+static const int HEADER_SIZE = 128;
+static const int FOOTER_SIZE = 64;
+static const int CORE_RECORD_SIZE = 56;
+static const int RECORD_SIZE = 88;
+static const int EXTENSION_SIZE = 32;
+static const uint16_t FORMAT_VERSION = 2;
+static const uint32_t SCHEMA_ID = 0x5D1FE1FDu;
+
+// Extension field offsets (relative to record start)
+static const int ORDER_ID_OFFSET = 56;
+static const int ORDER_ID_SIZE = 32;
+
+// ── Binary layout structs (packed, little-endian) ─────────────────────────────
+
+#pragma pack(push, 1)
+
+struct FileHeader {
+ uint8_t magic[8]; // "LEVELS01"
+ uint16_t version; // FORMAT_VERSION
+ uint8_t file_type; // 0 = Raw
+ uint8_t padding; // 0
+ uint32_t schema_id;
+ int64_t price_stream_id;
+ int64_t created_at;
+ int32_t price_scale;
+ int32_t quantity_scale;
+ uint32_t resampled_config_hash;
+ uint16_t record_size; // total bytes per record
+ uint8_t reserved[82]; // zeros
+};
+static_assert(sizeof(FileHeader) == 128, "FileHeader must be 128 bytes");
+
+struct CoreRecord {
+ int64_t observed_time; // 0
+ int64_t write_timestamp; // 8
+ int64_t price_stream_id; // 16
+ int64_t price; // 24
+ int64_t quantity; // 32
+ uint16_t _reserved; // 40
+ uint8_t record_type; // 42
+ uint8_t record_side; // 43
+ uint32_t sequence; // 44
+ uint16_t level; // 48
+ uint16_t flags; // 50
+ uint32_t crc32; // 52
+};
+static_assert(sizeof(CoreRecord) == 56, "CoreRecord must be 56 bytes");
+
+struct FullRecord {
+ int64_t observed_time; // 0
+ int64_t write_timestamp; // 8
+ int64_t price_stream_id; // 16
+ int64_t price; // 24
+ int64_t quantity; // 32
+ uint16_t _reserved; // 40
+ uint8_t record_type; // 42
+ uint8_t record_side; // 43
+ uint32_t sequence; // 44
+ uint16_t level; // 48
+ uint16_t flags; // 50
+ uint32_t crc32; // 52
+ uint8_t order_id[32]; // 56
+};
+static_assert(sizeof(FullRecord) == 88, "FullRecord must be 88 bytes");
+
+struct FileFooter {
+ int64_t record_count; // 0
+ int64_t delta_count; // 8
+ int64_t first_write_timestamp; // 16
+ int64_t last_write_timestamp; // 24
+ int64_t first_observed_time; // 32
+ int64_t last_observed_time; // 40
+ uint32_t file_crc32; // 48
+ uint8_t padding[4]; // 52
+ uint8_t magic_end[8]; // 56 "LEVEND01"
+};
+static_assert(sizeof(FileFooter) == 64, "FileFooter must be 64 bytes");
+
+#pragma pack(pop)
+
diff --git a/cross-language/cpp/write_test_file b/cross-language/cpp/write_test_file
new file mode 100755
index 0000000..65794c1
Binary files /dev/null and b/cross-language/cpp/write_test_file differ
diff --git a/cross-language/cpp/write_test_file.cpp b/cross-language/cpp/write_test_file.cpp
new file mode 100644
index 0000000..d2d17f3
--- /dev/null
+++ b/cross-language/cpp/write_test_file.cpp
@@ -0,0 +1,187 @@
+/**
+ * Writes a deterministic STLTH.Levels .raw test fixture using the canonical test vector.
+ * Output: ../fixtures/cpp_test.raw
+ *
+ * Assumes little-endian platform (x86/x64/ARM LE).
+ */
+
+#include
+#include
+#include
+#include
+
+#include "record.h"
+
+// ── CRC-32 lookup table (IEEE polynomial 0xEDB88320) ──────────────────────────
+static uint32_t crc32_table[256];
+static bool crc32_table_initialized = false;
+
+static void init_crc32_table() {
+ if (crc32_table_initialized) return;
+ for (uint32_t i = 0; i < 256; i++) {
+ uint32_t crc = i;
+ for (int j = 0; j < 8; j++) {
+ if (crc & 1)
+ crc = (crc >> 1) ^ 0xEDB88320u;
+ else
+ crc >>= 1;
+ }
+ crc32_table[i] = crc;
+ }
+ crc32_table_initialized = true;
+}
+
+static uint32_t compute_crc32(const uint8_t* data, size_t len) {
+ init_crc32_table();
+ uint32_t crc = 0xFFFFFFFFu;
+ for (size_t i = 0; i < len; i++) {
+ crc = (crc >> 8) ^ crc32_table[(crc ^ data[i]) & 0xFF];
+ }
+ return crc ^ 0xFFFFFFFFu;
+}
+
+static uint32_t compute_crc32_two(const uint8_t* data1, size_t len1,
+ const uint8_t* data2, size_t len2) {
+ init_crc32_table();
+ uint32_t crc = 0xFFFFFFFFu;
+ for (size_t i = 0; i < len1; i++)
+ crc = (crc >> 8) ^ crc32_table[(crc ^ data1[i]) & 0xFF];
+ for (size_t i = 0; i < len2; i++)
+ crc = (crc >> 8) ^ crc32_table[(crc ^ data2[i]) & 0xFF];
+ return crc ^ 0xFFFFFFFFu;
+}
+
+// ── Test vector ───────────────────────────────────────────────────────────────
+
+static const uint32_t TEST_SCHEMA_ID = 0x12345678;
+static const int64_t PRICE_STREAM_ID = 42;
+static const int64_t CREATED_AT = 1000000000;
+static const int32_t PRICE_SCALE = 2;
+static const int32_t QUANTITY_SCALE = 4;
+
+struct TestRecord {
+ int64_t observed_time;
+ int64_t write_timestamp;
+ int64_t price_stream_id;
+ int64_t price;
+ int64_t quantity;
+ uint8_t record_type;
+ uint8_t record_side;
+ uint32_t sequence;
+ uint16_t level;
+ uint16_t flags;
+};
+
+static const TestRecord TEST_RECORDS[] = {
+ // SNAP Bid
+ { 1000000, 2000000, 42, 50000, 100, 0, 0, 0, 0, 0 },
+ // DELTA Bid
+ { 1000100, 2000100, 42, 50000, 150, 1, 0, 1, 0, 0 },
+ // DELTA Ask
+ { 1000200, 2000200, 42, 51000, 75, 1, 1, 2, 0, 0 },
+};
+
+static const int NUM_RECORDS = sizeof(TEST_RECORDS) / sizeof(TEST_RECORDS[0]);
+
+int main() {
+ // Build output path relative to this source file's directory
+ std::string output_dir = std::string(__FILE__);
+ size_t last_sep = output_dir.find_last_of("/\\");
+ if (last_sep != std::string::npos)
+ output_dir = output_dir.substr(0, last_sep);
+ else
+ output_dir = ".";
+ std::string fixtures_dir = output_dir + "/../fixtures";
+ std::string output_path = fixtures_dir + "/cpp_test.raw";
+
+#ifdef _WIN32
+ _mkdir(fixtures_dir.c_str());
+#else
+ char cmd[512];
+ snprintf(cmd, sizeof(cmd), "mkdir -p \"%s\"", fixtures_dir.c_str());
+ system(cmd);
+#endif
+
+ FILE* fp = fopen(output_path.c_str(), "wb");
+ if (!fp) {
+ fprintf(stderr, "Failed to open %s for writing\n", output_path.c_str());
+ return 1;
+ }
+
+ // ── Write header ──────────────────────────────────────────────────────────
+ FileHeader header;
+ memset(&header, 0, sizeof(header));
+ memcpy(header.magic, "LEVELS01", 8);
+ header.version = FORMAT_VERSION;
+ header.file_type = 0; // Raw
+ header.padding = 0;
+ header.schema_id = TEST_SCHEMA_ID;
+ header.price_stream_id = PRICE_STREAM_ID;
+ header.created_at = CREATED_AT;
+ header.price_scale = PRICE_SCALE;
+ header.quantity_scale = QUANTITY_SCALE;
+ header.resampled_config_hash = 0;
+ header.record_size = RECORD_SIZE;
+ fwrite(&header, sizeof(header), 1, fp);
+
+ // ── Write records ─────────────────────────────────────────────────────────
+ uint8_t all_record_bytes[NUM_RECORDS * RECORD_SIZE];
+ int delta_count = 0;
+
+ for (int i = 0; i < NUM_RECORDS; i++) {
+ const TestRecord& tr = TEST_RECORDS[i];
+ FullRecord rec;
+ memset(&rec, 0, sizeof(rec));
+ rec.observed_time = tr.observed_time;
+ rec.write_timestamp = tr.write_timestamp;
+ rec.price_stream_id = tr.price_stream_id;
+ rec.price = tr.price;
+ rec.quantity = tr.quantity;
+ rec._reserved = 0;
+ rec.record_type = tr.record_type;
+ rec.record_side = tr.record_side;
+ rec.sequence = tr.sequence;
+ rec.level = tr.level;
+ rec.flags = tr.flags;
+ rec.crc32 = 0;
+ memset(rec.order_id, 0, ORDER_ID_SIZE);
+
+ // CRC covers bytes 0..51 + bytes 56..87 (core without CRC + extension)
+ rec.crc32 = compute_crc32_two(
+ reinterpret_cast(&rec), 52,
+ rec.order_id, ORDER_ID_SIZE);
+
+ memcpy(all_record_bytes + i * RECORD_SIZE, &rec, RECORD_SIZE);
+ fwrite(&rec, sizeof(rec), 1, fp);
+
+ if (tr.record_type == 1) delta_count++;
+ }
+
+ // ── Write footer ──────────────────────────────────────────────────────────
+ uint32_t file_crc = compute_crc32(all_record_bytes, sizeof(all_record_bytes));
+
+ FileFooter footer;
+ memset(&footer, 0, sizeof(footer));
+ footer.record_count = NUM_RECORDS;
+ footer.delta_count = delta_count;
+ footer.first_write_timestamp = TEST_RECORDS[0].write_timestamp;
+ footer.last_write_timestamp = TEST_RECORDS[NUM_RECORDS - 1].write_timestamp;
+ footer.first_observed_time = TEST_RECORDS[0].observed_time;
+ footer.last_observed_time = TEST_RECORDS[NUM_RECORDS - 1].observed_time;
+ footer.file_crc32 = file_crc;
+ memcpy(footer.magic_end, "LEVEND01", 8);
+
+ fwrite(&footer, sizeof(footer), 1, fp);
+ fclose(fp);
+
+ printf("Wrote %s\n", output_path.c_str());
+ printf(" Header: %zu bytes\n", sizeof(FileHeader));
+ printf(" Records: %d x %d = %d bytes\n",
+ NUM_RECORDS, RECORD_SIZE, NUM_RECORDS * RECORD_SIZE);
+ printf(" Footer: %zu bytes\n", sizeof(FileFooter));
+ printf(" Total: %zu bytes\n",
+ sizeof(FileHeader) + NUM_RECORDS * RECORD_SIZE + sizeof(FileFooter));
+ printf(" FileCRC: 0x%08X\n", file_crc);
+
+ return 0;
+}
diff --git a/cross-language/fixtures/cpp_test.raw b/cross-language/fixtures/cpp_test.raw
new file mode 100644
index 0000000..2b3b39c
Binary files /dev/null and b/cross-language/fixtures/cpp_test.raw differ
diff --git a/cross-language/fixtures/python_test.raw b/cross-language/fixtures/python_test.raw
new file mode 100644
index 0000000..2b3b39c
Binary files /dev/null and b/cross-language/fixtures/python_test.raw differ
diff --git a/cross-language/fixtures/typescript_test.raw b/cross-language/fixtures/typescript_test.raw
new file mode 100644
index 0000000..2b3b39c
Binary files /dev/null and b/cross-language/fixtures/typescript_test.raw differ
diff --git a/cross-language/generate-fixtures.sh b/cross-language/generate-fixtures.sh
new file mode 100755
index 0000000..add720a
--- /dev/null
+++ b/cross-language/generate-fixtures.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Generates Levels test fixtures from Python, TypeScript, and C++ implementations.
+# Run from the cross-language directory, or the script will cd there automatically.
+#
+# NOTE: After creating this file, make it executable:
+# chmod +x cross-language/generate-fixtures.sh
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+echo "=== Generating Levels cross-language test fixtures ==="
+echo ""
+
+# ── Codegen: generate record types from schema.fbs ───────────────────────────
+echo "--- Codegen ---"
+LEVELS="${LEVELS:-dotnet run --project ../src/Levels.Cli --}"
+$LEVELS codegen schema.fbs --language python --output python/
+$LEVELS codegen schema.fbs --language typescript --output typescript/
+$LEVELS codegen schema.fbs --language cpp --output cpp/
+echo ""
+
+# Ensure fixtures directory exists
+mkdir -p fixtures
+
+# ── Python ─────────────────────────────────────────────────────────────────────
+echo "--- Python ---"
+python3 python/write_test_file.py
+echo ""
+
+# ── TypeScript ─────────────────────────────────────────────────────────────────
+echo "--- TypeScript ---"
+cd typescript
+npm install --silent
+npx ts-node --esm write_test_file.ts
+cd "$SCRIPT_DIR"
+echo ""
+
+# ── C++ ────────────────────────────────────────────────────────────────────────
+echo "--- C++ ---"
+mkdir -p cpp/build
+cd cpp/build
+cmake .. -DCMAKE_BUILD_TYPE=Release
+make
+./write_test_file
+cd "$SCRIPT_DIR"
+echo ""
+
+# ── Verify all fixtures exist ──────────────────────────────────────────────────
+echo "=== Verifying fixtures ==="
+for lang in python typescript cpp; do
+ fixture="fixtures/${lang}_test.raw"
+ if [ -f "$fixture" ]; then
+ size=$(wc -c < "$fixture" | tr -d ' ')
+ echo " OK: $fixture ($size bytes)"
+ else
+ echo " MISSING: $fixture"
+ exit 1
+ fi
+done
+
+echo ""
+echo "=== All fixtures generated successfully ==="
diff --git a/cross-language/python/record.py b/cross-language/python/record.py
new file mode 100644
index 0000000..b7ad6a7
--- /dev/null
+++ b/cross-language/python/record.py
@@ -0,0 +1,99 @@
+#
+# Generated from .fbs schema — do not edit by hand.
+
+from __future__ import annotations
+
+import struct
+import binascii
+
+# ── Constants ──────────────────────────────────────────────────────────────────
+HEADER_SIZE = 128
+FOOTER_SIZE = 64
+CORE_RECORD_SIZE = 56
+RECORD_SIZE = 88
+EXTENSION_SIZE = 32
+FORMAT_VERSION = 2
+SCHEMA_ID = 0x5D1FE1FD
+
+HEADER_MAGIC = b"LEVELS01"
+FOOTER_MAGIC = b"LEVEND01"
+FILE_TYPE_RAW = 0
+
+# Extension fields: (name, offset_from_core, size)
+EXTENSION_FIELDS = [
+ ("order_id", 0, 32),
+]
+
+# struct.pack format for the 56-byte core record (little-endian)
+# int64×5, uint16, uint8×2, uint32, uint16×2, uint32
+CORE_FMT = " int:
+ """Compute CRC-32 (IEEE) and return as unsigned 32-bit."""
+ return binascii.crc32(data) & 0xFFFFFFFF
+
+
+def pack_header(price_stream_id: int, created_at: int,
+ price_scale: int, quantity_scale: int,
+ schema_id: int = SCHEMA_ID,
+ file_type: int = FILE_TYPE_RAW,
+ resampled_config_hash: int = 0) -> bytes:
+ buf = bytearray(HEADER_SIZE)
+ buf[0:8] = HEADER_MAGIC
+ struct.pack_into(" bytes:
+ """Pack a full record (core + extension) with computed CRC."""
+ core_data = struct.pack(
+ CORE_FMT,
+ observed_time, write_timestamp, price_stream_id, price, quantity,
+ 0, # _reserved
+ record_type, record_side, sequence, level, flags,
+ 0, # CRC placeholder
+ )
+ ext = extension_bytes if extension_bytes is not None else b'\x00' * EXTENSION_SIZE
+ if len(ext) != EXTENSION_SIZE:
+ raise ValueError(f"Extension bytes must be {EXTENSION_SIZE} bytes, got {len(ext)}")
+ # CRC covers bytes 0..51 + extension bytes (skipping CRC field at 52..55)
+ crc = compute_crc32(core_data[:52] + ext)
+ core_data = struct.pack(
+ CORE_FMT,
+ observed_time, write_timestamp, price_stream_id, price, quantity,
+ 0, record_type, record_side, sequence, level, flags,
+ crc,
+ )
+ return core_data + ext
+
+
+def pack_footer(record_count: int, delta_count: int,
+ first_write_ts: int, last_write_ts: int,
+ first_observed: int, last_observed: int,
+ file_crc: int) -> bytes:
+ buf = bytearray(FOOTER_SIZE)
+ struct.pack_into("=12"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
+ "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.0.3",
+ "@jridgewell/sourcemap-codec": "^1.4.10"
+ }
+ },
+ "node_modules/@tsconfig/node10": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
+ "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node12": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
+ "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node14": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
+ "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tsconfig/node16": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
+ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.37",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
+ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/arg": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+ "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/crc-32": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
+ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+ "license": "Apache-2.0",
+ "bin": {
+ "crc32": "bin/crc32.njs"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/create-require": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/diff": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
+ "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
+ "node_modules/make-error": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/ts-node": {
+ "version": "10.9.2",
+ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
+ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@cspotcode/source-map-support": "^0.8.0",
+ "@tsconfig/node10": "^1.0.7",
+ "@tsconfig/node12": "^1.0.7",
+ "@tsconfig/node14": "^1.0.0",
+ "@tsconfig/node16": "^1.0.2",
+ "acorn": "^8.4.1",
+ "acorn-walk": "^8.1.1",
+ "arg": "^4.1.0",
+ "create-require": "^1.1.0",
+ "diff": "^4.0.1",
+ "make-error": "^1.1.1",
+ "v8-compile-cache-lib": "^3.0.1",
+ "yn": "3.1.1"
+ },
+ "bin": {
+ "ts-node": "dist/bin.js",
+ "ts-node-cwd": "dist/bin-cwd.js",
+ "ts-node-esm": "dist/bin-esm.js",
+ "ts-node-script": "dist/bin-script.js",
+ "ts-node-transpile-only": "dist/bin-transpile.js",
+ "ts-script": "dist/bin-script-deprecated.js"
+ },
+ "peerDependencies": {
+ "@swc/core": ">=1.2.50",
+ "@swc/wasm": ">=1.2.50",
+ "@types/node": "*",
+ "typescript": ">=2.7"
+ },
+ "peerDependenciesMeta": {
+ "@swc/core": {
+ "optional": true
+ },
+ "@swc/wasm": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "peer": true,
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/v8-compile-cache-lib": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
+ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yn": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+ "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ }
+ }
+}
diff --git a/cross-language/typescript/package.json b/cross-language/typescript/package.json
new file mode 100644
index 0000000..7c5a7f6
--- /dev/null
+++ b/cross-language/typescript/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "levels-cross-language-ts",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "crc-32": "^1.2.2"
+ },
+ "devDependencies": {
+ "ts-node": "^10.9.2",
+ "typescript": "^5.4.0",
+ "@types/node": "^20.0.0"
+ }
+}
diff --git a/cross-language/typescript/record.ts b/cross-language/typescript/record.ts
new file mode 100644
index 0000000..3986db3
--- /dev/null
+++ b/cross-language/typescript/record.ts
@@ -0,0 +1,109 @@
+//
+// Generated from .fbs schema — do not edit by hand.
+
+import CRC32 from "crc-32";
+
+// ── Constants ──────────────────────────────────────────────────────────────────
+export const HEADER_SIZE = 128;
+export const FOOTER_SIZE = 64;
+export const CORE_RECORD_SIZE = 56;
+export const RECORD_SIZE = 88;
+export const EXTENSION_SIZE = 32;
+export const FORMAT_VERSION = 2;
+export const SCHEMA_ID = 0x5D1FE1FD;
+
+export const HEADER_MAGIC = Buffer.from("LEVELS01", "ascii");
+export const FOOTER_MAGIC = Buffer.from("LEVEND01", "ascii");
+export const FILE_TYPE_RAW = 0;
+
+// Extension fields: [name, offsetFromCore, size]
+export const EXTENSION_FIELDS: [string, number, number][] = [
+ ["order_id", 0, 32],
+];
+
+export interface RecordData {
+ observedTime: bigint;
+ writeTimestamp: bigint;
+ priceStreamId: bigint;
+ price: bigint;
+ quantity: bigint;
+ recordType: number;
+ recordSide: number;
+ sequence: number;
+ level: number;
+ flags: number;
+}
+
+export function computeCrc32(buf: Buffer): number {
+ return CRC32.buf(buf) >>> 0;
+}
+
+export function packHeader(
+ priceStreamId: bigint,
+ createdAt: bigint,
+ priceScale: number,
+ quantityScale: number,
+ schemaId: number = SCHEMA_ID,
+ fileType: number = FILE_TYPE_RAW,
+ resampledConfigHash: number = 0,
+): Buffer {
+ const buf = Buffer.alloc(HEADER_SIZE);
+ HEADER_MAGIC.copy(buf, 0);
+ buf.writeUInt16LE(FORMAT_VERSION, 8);
+ buf[10] = fileType;
+ buf[11] = 0; // padding
+ buf.writeUInt32LE(schemaId, 12);
+ buf.writeBigInt64LE(priceStreamId, 16);
+ buf.writeBigInt64LE(createdAt, 24);
+ buf.writeInt32LE(priceScale, 32);
+ buf.writeInt32LE(quantityScale, 36);
+ buf.writeUInt32LE(resampledConfigHash, 40);
+ buf.writeUInt16LE(RECORD_SIZE, 44);
+ return buf;
+}
+
+export function packRecord(rec: RecordData, extensionBytes?: Buffer): Buffer {
+ const buf = Buffer.alloc(RECORD_SIZE);
+ buf.writeBigInt64LE(rec.observedTime, 0);
+ buf.writeBigInt64LE(rec.writeTimestamp, 8);
+ buf.writeBigInt64LE(rec.priceStreamId, 16);
+ buf.writeBigInt64LE(rec.price, 24);
+ buf.writeBigInt64LE(rec.quantity, 32);
+ buf.writeUInt16LE(0, 40); // _reserved
+ buf[42] = rec.recordType;
+ buf[43] = rec.recordSide;
+ buf.writeUInt32LE(rec.sequence, 44);
+ buf.writeUInt16LE(rec.level, 48);
+ buf.writeUInt16LE(rec.flags, 50);
+ // Extension bytes (already zero from Buffer.alloc)
+ if (extensionBytes) {
+ extensionBytes.copy(buf, CORE_RECORD_SIZE, 0, EXTENSION_SIZE);
+ }
+ // CRC covers bytes 0..51 + bytes 56..RECORD_SIZE
+ const crc = computeCrc32(Buffer.concat([buf.subarray(0, 52), buf.subarray(56)]));
+ buf.writeUInt32LE(crc, 52);
+ return buf;
+}
+
+export function packFooter(
+ recordCount: bigint,
+ deltaCount: bigint,
+ firstWriteTs: bigint,
+ lastWriteTs: bigint,
+ firstObserved: bigint,
+ lastObserved: bigint,
+ fileCrc: number,
+): Buffer {
+ const buf = Buffer.alloc(FOOTER_SIZE);
+ buf.writeBigInt64LE(recordCount, 0);
+ buf.writeBigInt64LE(deltaCount, 8);
+ buf.writeBigInt64LE(firstWriteTs, 16);
+ buf.writeBigInt64LE(lastWriteTs, 24);
+ buf.writeBigInt64LE(firstObserved, 32);
+ buf.writeBigInt64LE(lastObserved, 40);
+ buf.writeUInt32LE(fileCrc, 48);
+ // 4 bytes padding at 52
+ FOOTER_MAGIC.copy(buf, 56);
+ return buf;
+}
+
diff --git a/cross-language/typescript/write_test_file.ts b/cross-language/typescript/write_test_file.ts
new file mode 100644
index 0000000..570fd95
--- /dev/null
+++ b/cross-language/typescript/write_test_file.ts
@@ -0,0 +1,113 @@
+/**
+ * Writes a deterministic STLTH.Levels .raw test fixture using the canonical test vector.
+ * Output: ../fixtures/typescript_test.raw
+ */
+
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { fileURLToPath } from "node:url";
+import {
+ HEADER_SIZE,
+ FOOTER_SIZE,
+ RECORD_SIZE,
+ computeCrc32,
+ packHeader,
+ packRecord,
+ packFooter,
+ type RecordData,
+} from "./record.ts";
+
+// ── Test vector ────────────────────────────────────────────────────────────────
+const SCHEMA_ID = 0x12345678;
+const PRICE_STREAM_ID = 42n;
+const CREATED_AT = 1000000000n;
+const PRICE_SCALE = 2;
+const QUANTITY_SCALE = 4;
+
+const RECORDS: RecordData[] = [
+ {
+ observedTime: 1000000n,
+ writeTimestamp: 2000000n,
+ priceStreamId: 42n,
+ price: 50000n,
+ quantity: 100n,
+ recordType: 0, // Snap
+ recordSide: 0, // Bid
+ sequence: 0,
+ level: 0,
+ flags: 0,
+ },
+ {
+ observedTime: 1000100n,
+ writeTimestamp: 2000100n,
+ priceStreamId: 42n,
+ price: 50000n,
+ quantity: 150n,
+ recordType: 1, // Delta
+ recordSide: 0, // Bid
+ sequence: 1,
+ level: 0,
+ flags: 0,
+ },
+ {
+ observedTime: 1000200n,
+ writeTimestamp: 2000200n,
+ priceStreamId: 42n,
+ price: 51000n,
+ quantity: 75n,
+ recordType: 1, // Delta
+ recordSide: 1, // Ask
+ sequence: 2,
+ level: 0,
+ flags: 0,
+ },
+];
+
+function main(): void {
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ const outputDir = path.join(__dirname, "..", "fixtures");
+ fs.mkdirSync(outputDir, { recursive: true });
+ const outputPath = path.join(outputDir, "typescript_test.raw");
+
+ const header = packHeader(PRICE_STREAM_ID, CREATED_AT, PRICE_SCALE, QUANTITY_SCALE, SCHEMA_ID);
+
+ const recordBuffers: Buffer[] = [];
+ for (const rec of RECORDS) {
+ recordBuffers.push(packRecord(rec));
+ }
+
+ const allRecordBytes = Buffer.concat(recordBuffers);
+ const fileCrc = computeCrc32(allRecordBytes);
+
+ const deltaCount = RECORDS.filter((r) => r.recordType === 1).length;
+ const firstWriteTs = RECORDS[0].writeTimestamp;
+ const lastWriteTs = RECORDS[RECORDS.length - 1].writeTimestamp;
+
+ const observedTimes = RECORDS.map((r) => r.observedTime);
+ const firstObserved = observedTimes.reduce((a, b) => (a < b ? a : b));
+ const lastObserved = observedTimes.reduce((a, b) => (a > b ? a : b));
+
+ const footer = packFooter(
+ BigInt(RECORDS.length),
+ BigInt(deltaCount),
+ firstWriteTs,
+ lastWriteTs,
+ firstObserved,
+ lastObserved,
+ fileCrc,
+ );
+
+ const fullFile = Buffer.concat([header, allRecordBytes, footer]);
+ fs.writeFileSync(outputPath, fullFile);
+
+ console.log(`Wrote ${outputPath}`);
+ console.log(` Header: ${HEADER_SIZE} bytes`);
+ console.log(
+ ` Records: ${RECORDS.length} x ${RECORD_SIZE} = ${RECORDS.length * RECORD_SIZE} bytes`,
+ );
+ console.log(` Footer: ${FOOTER_SIZE} bytes`);
+ console.log(` Total: ${fullFile.length} bytes`);
+ console.log(` FileCRC: 0x${fileCrc.toString(16).toUpperCase().padStart(8, "0")}`);
+}
+
+main();
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..46dc064
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,208 @@
+# Architecture
+
+## Data Pipeline
+
+```
+ +-----------+
+ | IDataSink | (typed records from your code)
+ +-----+-----+
+ |
+ +-----v---------+
+ | DataSinkAdapter| (maps to RawMarketEvent)
+ +-----+---------+
+ |
+ +-------v---------+
+ | PriceStreamSink | (partitioned writer, WAL, circuit breaker)
+ +-------+---------+
+ |
+ +-----------v-----------+
+ | BinaryRecordWriter | (header + records + footer)
+ +-----------+-----------+
+ |
+ +----------v----------+
+ | DataFlowBus | (channel-based pub/sub)
+ +--+------+------+---+
+ | | |
+ +-------------+ +---+----+ +--------+
+ |FileIndex | |Compact | |Period |
+ |SyncHandler | |ion | |Promot. |
+ +-------------+ +---+----+ +---+----+
+ | |
+ +----v---+ +---v--------+
+ | AGG | | PERIOD |
+ | files | | files |
+ +--------+ +---+--------+
+ |
+ +------v-------+
+ | Resampled |
+ | (OHLCV/L1) |
+ +--------------+
+```
+
+## File Types
+
+Data progresses through four file types representing increasing levels of quality assurance:
+
+| Type | Extension | Description |
+|------|-----------|-------------|
+| **RAW** | `.raw` | Ingested events as-is. Rollovers by size (256 MB) or time (1 hour). |
+| **AGG** | `.agg` | Compacted: event-sourced replay with synthetic snapshots inserted. |
+| **PERIOD** | `.period` | Quality-checked AGG promoted after passing health checks. |
+| **RESAMPLED** | `.resampled` | Derived OHLCV bars or top-of-book snapshots. |
+
+## Binary Format
+
+All files share the same layout. Little-endian byte order.
+
+### File Layout
+
+```
+[Header: 128 bytes][Record 0][Record 1]...[Record N][Footer: 64 bytes]
+```
+
+### Header (128 bytes)
+
+| Offset | Size | Field | Description |
+|--------|------|-------|-------------|
+| 0 | 8 | Magic | `LEVELS01` (ASCII) |
+| 8 | 2 | Version | Format version (currently `2`) |
+| 10 | 1 | FileType | 0=Raw, 1=Agg, 2=Period, 3=Resampled |
+| 12 | 4 | SchemaId | FNV-1a hash of the `.fbs` schema |
+| 16 | 8 | PriceStreamId | XxHash64 of venue + symbol |
+| 24 | 8 | CreatedAt | File creation timestamp (nanoseconds) |
+| 32 | 4 | PriceScale | Decimal places for price fields |
+| 36 | 4 | QuantityScale | Decimal places for quantity fields |
+| 40 | 4 | ResampledConfigHash | XxHash32 of resampled config (0 for non-resampled) |
+| 44 | 2 | RecordSize | Bytes per record (56 + extensions) |
+| 46-127 | 82 | Reserved | Zero-filled |
+
+### Core Record (56 bytes)
+
+| Offset | Size | Field | Type | Description |
+|--------|------|-------|------|-------------|
+| 0 | 8 | ObservedTime | int64 | Exchange timestamp |
+| 8 | 8 | WriteTimestamp | int64 | Local write timestamp |
+| 16 | 8 | PriceStreamId | int64 | Stream identifier |
+| 24 | 8 | Price | int64 | Scaled price value |
+| 32 | 8 | Quantity | int64 | Scaled quantity value |
+| 40 | 2 | Reserved | uint16 | Reserved |
+| 42 | 1 | Type | uint8 | 0=Snap, 1=Delta, 2=Tombstone |
+| 43 | 1 | Side | uint8 | 0=Bid, 1=Ask, 2=Unknown |
+| 44 | 4 | Sequence | uint32 | Monotonic sequence number |
+| 48 | 2 | Level | uint16 | Orderbook level index |
+| 50 | 2 | Flags | uint16 | Bit flags (0x0001=SyntheticSnap, 0x0002=IsOwner) |
+| 52 | 4 | Crc32 | uint32 | CRC32 of bytes [0:52] + extension bytes |
+
+Extension fields (bytes 56+) are defined by the schema. For example, a 32-byte OrderId field produces 88-byte records.
+
+### Footer (64 bytes)
+
+| Offset | Size | Field | Type |
+|--------|------|-------|------|
+| 0 | 8 | RecordCount | int64 |
+| 8 | 8 | DeltaCount | int64 |
+| 16 | 8 | FirstWriteTimestamp | int64 |
+| 24 | 8 | LastWriteTimestamp | int64 |
+| 32 | 8 | FirstObservedTime | int64 |
+| 40 | 8 | LastObservedTime | int64 |
+| 48 | 4 | FileCrc32 | uint32 |
+| 52 | 4 | FooterCrc32 | uint32 |
+| 56 | 8 | MagicEnd | `LEVEND01` (ASCII) |
+
+### CRC32 Calculation
+
+Record CRC covers bytes `[0:52]` (core without CRC field) concatenated with any extension bytes. Uses the IEEE polynomial via `System.IO.Hashing.Crc32`.
+
+## Projects
+
+The solution contains 16 source projects:
+
+| Project | Target | Purpose |
+|---------|--------|---------|
+| Levels.Core | net10.0 | Binary format, interfaces, WAL, diagnostics |
+| Levels.SourceGen | netstandard2.0 | Roslyn incremental generator for `.fbs` schemas |
+| Levels.Sinks | net10.0 | Partitioned ingestion, circuit breaker |
+| Levels.DataFlow | net10.0 | Channel-based async pub/sub bus |
+| Levels.Query | net10.0 | In-memory file index, orderbook projection |
+| Levels.Compaction | net10.0 | Event-sourcing replay, synthetic snapshots |
+| Levels.Period | net10.0 | Quality checks, promotion/demotion |
+| Levels.Resampled | net10.0 | OHLCV bars, top-of-book snapshots |
+| Levels.Export | net10.0 | CSV, Parquet, Avro export adapters |
+| Levels.Hosting | net10.0 | DI registration, OpenTelemetry setup |
+| Levels.Hints | net10.0 | LiteDB-based query hints |
+| Levels.Protocol | net10.0 | Wire protocol (frame reader/writer) |
+| Levels.Server | net10.0 | Standalone TCP server |
+| Levels.Client | net10.0 | TCP client library |
+| Levels.Web | net10.0 | ASP.NET Core REST API |
+| Levels.Cli | net10.0 | Command-line tool |
+
+## DataFlowBus
+
+The `DataFlowBus` is a channel-based async pub/sub system that connects the write pipeline to downstream consumers. It runs as an `IHostedService`.
+
+**Message types:**
+- `RecordWrittenMessage` -- emitted after every record write
+- `FileSealedMessage` -- emitted when a file is rolled over and sealed
+- `AggCreatedMessage` -- emitted after compaction produces an AGG file
+
+**Handlers:**
+- `FileIndexSyncHandler` -- keeps the in-memory `FileIndex` current
+- `EventSourcingCompaction` -- triggers compaction on window close
+- `PeriodPromotion` -- runs quality checks and promotes AGG to PERIOD
+- `LiveResampledHandler` -- emits resampled output in real-time
+- `HintsDbSyncHandler` -- syncs hints database
+
+Each handler has a dedicated bounded channel (capacity: configurable via `BackpressureLimit`). If a handler's channel is full, `RecordWrittenMessage` is dropped (counted in metrics). `FileSealedMessage` and `AggCreatedMessage` throw `DataFlowBackpressureException` or await space on the async path.
+
+## Write-Ahead Log (WAL)
+
+The WAL provides crash recovery for in-flight records. Each entry contains:
+
+```
+[StreamId:8][VenueLen:2][Venue:N][SymbolLen:2][Symbol:M][RecordSize:4][RecordBytes:R][CRC32:4]
+```
+
+On recovery, valid entries are replayed and entries with CRC mismatches are skipped. The WAL is truncated once durability is confirmed.
+
+## Compaction
+
+Compaction replays raw events through an `OrderbookReplayEngine` to produce AGG files:
+
+1. Raw files are grouped by stream and compaction window
+2. When a window closes (wall clock > window end + grace period), compaction begins
+3. All raw records are replayed in order, rebuilding orderbook state
+4. Synthetic snapshots are inserted every N deltas or T time
+5. Output is written as an `.agg` file
+
+## Period Promotion
+
+AGG files are promoted to PERIOD after passing health checks:
+
+- **CrossedBookDetector** -- ensures best bid < best ask at all times
+- **MissingValueDetector** -- verifies no gaps exceed the configured threshold (default: 5 minutes)
+- **SequenceIntegrityChecker** -- validates monotonic sequence numbers
+- **SnapCoverageChecker** -- ensures sufficient snapshot coverage for recovery
+
+Failed checks produce a manifest file with failure reasons. A `DemotionService` can retroactively demote PERIOD files.
+
+## Query Resolution
+
+The `QueryLayer` resolves a `(PriceStreamId, startTime, endTime)` query to matching file entries from the `FileIndex`. The `OrderbookProjection` class replays matched records to reconstruct:
+
+- **L1** -- best bid/ask
+- **L2** -- full depth (all price levels)
+- **L3** -- individual order tracking (requires OrderId extension field)
+
+## Hints Engine
+
+The `HintsDb` is a LiteDB-backed persistent index that mirrors `FileIndex`. It speeds up startup by avoiding full disk scans. A consistency check validates hints against actual files on disk.
+
+## Observability
+
+OpenTelemetry meter: `Levels`, activity source: `Levels`.
+
+**Counters:** `pricestorage.records.written`, `pricestorage.files.sealed`, `pricestorage.circuit_breaker.trips`, `pricestorage.compaction.completed`, `pricestorage.healthcheck.passed`, `pricestorage.healthcheck.failed`, `pricestorage.promotions.completed`, `pricestorage.queries.executed`, `pricestorage.dataflow.backpressure`, `pricestorage.tcp.messages`, `pricestorage.archival.files_deleted`
+
+**Histograms:** `pricestorage.record.write_latency` (ms), `pricestorage.compaction.duration` (ms), `pricestorage.query.latency` (ms)
+
+**UpDownCounters:** `pricestorage.streams.active`, `pricestorage.connections.active`
diff --git a/docs/cli.md b/docs/cli.md
new file mode 100644
index 0000000..b74508b
--- /dev/null
+++ b/docs/cli.md
@@ -0,0 +1,176 @@
+# CLI Reference
+
+The `levels` CLI tool provides commands for inspecting data files, running compaction, generating schema code, resampling, and exporting data.
+
+## Running the CLI
+
+```bash
+dotnet run --project src/Levels.Cli -- [options]
+```
+
+Or, if installed as a dotnet tool:
+
+```bash
+levels [options]
+```
+
+## Commands
+
+### inspect
+
+Examine a Levels data file -- header metadata, footer statistics, and optionally individual records.
+
+```bash
+levels inspect [--records]
+```
+
+**Arguments:**
+- `` -- path to a `.raw`, `.agg`, `.period`, or `.resampled` file
+
+**Options:**
+- `--records` -- print each record's type, side, price, quantity, sequence, level, flags, and observed time
+
+**Example:**
+
+```bash
+levels inspect data/binance/1234567890/20250115_100000.raw
+```
+
+Output:
+
+```
+File: data/binance/1234567890/20250115_100000.raw
+ Version: 2
+ FileType: Raw
+ PriceStreamId: 1234567890
+ SchemaId: 0xE4E8A928
+ PriceScale: 2
+ QuantityScale: 8
+ CreatedAt: 2025-01-15T10:00:00.0000000Z
+ Partial: False
+ RecordCount: 4200
+ DeltaCount: 3800
+ FirstObserved: 1736935200000
+ LastObserved: 1736938800000
+ FirstWrite: 1736935200100
+ LastWrite: 1736938800050
+ FileCRC: 0xA1B2C3D4
+```
+
+With `--records`:
+
+```
+Records:
+ [0] Type=Snap Side=Bid Price=10350042 Qty=150000000 Seq=1 Level=0 Flags=0x0000 Observed=1736935200000
+ [1] Type=Snap Side=Ask Price=10350142 Qty=120000000 Seq=2 Level=0 Flags=0x0000 Observed=1736935200000
+ ...
+```
+
+### compact
+
+Run compaction on a data directory, replaying RAW files into AGG files with synthetic snapshots.
+
+```bash
+levels compact [--window ]
+```
+
+**Arguments:**
+- `` -- directory containing data files
+
+**Options:**
+- `--window ` -- compaction window in hours (default: `1`)
+
+**Example:**
+
+```bash
+levels compact ./data --window 2
+```
+
+The command scans all `.raw` files, groups them by stream and time window, replays events through the `EventSourcingCompaction` engine, and writes `.agg` output files. The grace period is set to zero for CLI compaction (immediate processing).
+
+### codegen
+
+Generate typed accessors from a FlatBuffers `.fbs` schema file.
+
+```bash
+levels codegen [--output ] [--language ]
+```
+
+**Arguments:**
+- `` -- path to the schema file
+
+**Options:**
+- `--output ` -- output directory (default: current directory)
+- `--language ` -- target language (default: `csharp`)
+
+**Supported languages:**
+
+| Language flag | Aliases | Output file |
+|---------------|---------|-------------|
+| `csharp` | `cs` | `Record.g.cs` |
+| `python` | `py` | `record.py` |
+| `typescript` | `ts` | `record.ts` |
+| `cpp` | `c++` | `record.h` |
+
+**Examples:**
+
+```bash
+# C# (default)
+levels codegen schema.fbs --output ./Generated
+
+# Python
+levels codegen schema.fbs --output ./python --language python
+
+# TypeScript
+levels codegen schema.fbs --output ./ts --language typescript
+
+# C++
+levels codegen schema.fbs --output ./cpp --language cpp
+```
+
+### resample
+
+Run batch resampling on existing data for a specific stream.
+
+```bash
+levels resample --venue --stream --window
+```
+
+**Arguments:**
+- `` -- directory containing data files
+
+**Options (all required):**
+- `--venue ` -- venue name (e.g., `binance`)
+- `--stream ` -- symbol name (e.g., `BTCUSDT`)
+- `--window ` -- resampling window in seconds (default: `60`)
+
+**Example:**
+
+```bash
+levels resample ./data --venue binance --stream BTCUSDT --window 300
+```
+
+### export
+
+Export data for a specific stream to CSV, Parquet, or Avro format.
+
+```bash
+levels export --venue --stream --format [--output ]
+```
+
+**Arguments:**
+- `` -- directory containing data files
+
+**Options:**
+- `--venue ` -- venue name (**required**)
+- `--stream ` -- symbol name (**required**)
+- `--format ` -- output format: `csv`, `parquet`, or `avro` (default: `csv`)
+- `--output ` -- output file path (default: `{symbol}.{format}`)
+
+**Example:**
+
+```bash
+levels export ./data --venue binance --stream BTCUSDT --format parquet --output btcusdt.parquet
+```
+
+The export command builds a `FileIndex` from disk, resolves all files for the stream, and pipes records through the `ExportPipeline` to the chosen adapter.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..38dbe1f
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,154 @@
+# Configuration Reference
+
+## LevelsOptions
+
+The primary configuration class, set via `AddLevels()` or `AddLevels()`.
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `DataPath` | string | **required** | Base directory for all data files |
+| `BackpressureLimit` | int | `8192` | Channel capacity before write throttling |
+| `RolloverSize` | long | `268435456` (256 MB) | Max file size before creating a new file |
+| `RolloverInterval` | TimeSpan | 1 hour | Time-based file rollover |
+| `PriceScale` | int | `0` | Decimal places for price fields |
+| `QuantityScale` | int | `0` | Decimal places for quantity fields |
+| `CompactionWindow` | TimeSpan | 1 hour | Time window for grouping RAW files into AGG |
+| `SyntheticSnapIntervalDeltas` | int | `1000` | Insert synthetic snapshot every N deltas during compaction |
+| `SyntheticSnapIntervalTime` | TimeSpan? | null | Insert synthetic snapshot every T time (optional) |
+| `RetentionWindow` | TimeSpan | 7 days | How long to keep RAW files after compaction |
+| `WindowGracePeriod` | TimeSpan | 5 minutes | Grace period before closing a compaction window |
+| `ConfigVersion` | string | `"v1"` | Version tag for period promotion config |
+| `EnableCompaction` | bool | `true` | Enable automatic RAW -> AGG compaction |
+| `EnablePeriodPromotion` | bool | `true` | Enable automatic AGG -> PERIOD promotion |
+| `FlushThresholdMs` | int | `0` | Flush interval in milliseconds (0 = immediate) |
+| `FlushBufferSize` | int | `0` | Buffer size before flush (0 = unbuffered) |
+| `SchemaId` | uint | `0` | Schema ID for file headers (auto-set by `AddLevels()`) |
+| `RecordSize` | int | `56` | Bytes per record (auto-set by `AddLevels()`) |
+| `EnableMetrics` | bool | `false` | Enable OpenTelemetry metrics via OTLP |
+| `OtlpEndpoint` | string? | null | OTLP collector endpoint |
+| `EnableTracing` | bool | `false` | Enable OpenTelemetry distributed tracing |
+
+## SinkConfig
+
+Internal configuration for `PriceStreamSink`. Built automatically from `LevelsOptions` during DI registration.
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `OutputPath` | string | **required** | Data directory (set from `DataPath`) |
+| `BackpressureLimit` | int | `8192` | Channel capacity |
+| `RolloverSize` | long | 256 MB | File size rollover threshold |
+| `RolloverInterval` | TimeSpan | 1 hour | Time-based rollover |
+| `PriceScale` | int | `0` | Price decimal places |
+| `QuantityScale` | int | `0` | Quantity decimal places |
+| `SchemaId` | uint | `0` | Schema ID for headers |
+| `RecordSize` | int | `56` | Record size in bytes |
+| `FlushThresholdMs` | int | `0` | Flush interval |
+| `FlushBufferSize` | int | `0` | Buffer size |
+| `ConsumerPartitions` | int | `1` | Number of write partitions |
+| `MaxWriteFailures` | int | `5` | Failures before circuit breaker trips |
+| `CircuitBreakerCooldown` | TimeSpan | 60 seconds | Cooldown after circuit breaker trip |
+| `DataFlowBus` | DataFlowBus? | null | Event bus for downstream handlers |
+
+## CompactionConfig
+
+Controls the event-sourcing compaction pipeline.
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `DataPath` | string | **required** | Data directory |
+| `CompactionWindow` | TimeSpan | 1 hour | Window for grouping RAW -> AGG |
+| `SyntheticSnapIntervalDeltas` | int | `1000` | Snapshots every N deltas |
+| `SyntheticSnapIntervalTime` | TimeSpan? | null | Snapshots every T time |
+| `RetentionWindow` | TimeSpan | 7 days | RAW file retention |
+| `WindowGracePeriod` | TimeSpan | 5 minutes | Grace period for late data |
+
+## ArchivalConfig
+
+Controls automatic deletion of old files.
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `DataPath` | string | **required** | Data directory |
+| `RawRetention` | TimeSpan | 7 days | How long to keep RAW files |
+| `AggRetention` | TimeSpan | 30 days | How long to keep AGG files |
+| `PeriodRetention` | TimeSpan? | null | How long to keep PERIOD files (null = forever) |
+| `ScanInterval` | TimeSpan | 1 hour | How often to scan for expired files |
+
+## QueryConfig
+
+Configuration for the query layer.
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `DataPath` | string | **required** | Data directory |
+| `CompactionWindow` | TimeSpan | 1 hour | Compaction window (for query resolution) |
+
+## PeriodConfig
+
+Controls period promotion and data quality checks.
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `DataPath` | string | **required** | Data directory |
+| `CompactionWindow` | TimeSpan | 1 hour | Compaction window |
+| `ConfigVersion` | string | **required** | Version tag for promotion rules |
+| `MissingValueConfig` | MissingValueConfig | default | Gap detection thresholds |
+
+The `MissingValueConfig` controls per-stream thresholds for the `MissingValueDetector` health check. The default gap threshold is 5 minutes.
+
+## ResampledStreamConfig
+
+Configuration for resampled output (OHLCV bars or top-of-book snapshots).
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `ConfigVersion` | string | **required** | Version tag |
+| `SourceStreams` | IReadOnlyList\ | **required** | Input streams to resample |
+| `ResamplingWindow` | TimeSpan | **required** | Bar/snapshot interval |
+| `OutputType` | ResampledOutputType | `TopOfBook` | `TopOfBook` (0) or `OhlcvBar` (1) |
+| `Venue` | string | **required** | Output venue name |
+| `OutputPath` | string | **required** | Directory for resampled files |
+| `PriceScale` | int | `0` | Price decimal places |
+| `QuantityScale` | int | `0` | Quantity decimal places |
+
+The config hash (XxHash32 of `ConfigVersion`) is written to the file header's `ResampledConfigHash` field.
+
+## Server Configuration (YAML)
+
+The TCP server uses a YAML configuration file. See [Server Mode](server-mode.md) for the full format.
+
+```yaml
+DataPath: ./data
+Port: 5050
+Schema:
+ DllPath: path/to/schema.dll
+ TypeName: MyNamespace.Record
+Levels:
+ PriceScale: 2
+ QuantityScale: 8
+ RolloverSize: 268435456
+ RolloverInterval: "01:00:00"
+ BackpressureLimit: 8192
+ EnableCompaction: true
+ EnablePeriodPromotion: false
+ CompactionWindow: "01:00:00"
+Telemetry:
+ EnableMetrics: false
+ OtlpEndpoint: null
+ EnableTracing: false
+```
+
+## Web Configuration (appsettings.json)
+
+The web API uses standard ASP.NET Core configuration. See [Web API](web-api.md) for details.
+
+```json
+{
+ "Levels": {
+ "DataPath": "./data",
+ "EnableMetrics": true,
+ "OtlpEndpoint": "http://localhost:4317",
+ "EnableTracing": false
+ }
+}
+```
diff --git a/docs/cross-language.md b/docs/cross-language.md
new file mode 100644
index 0000000..c196672
--- /dev/null
+++ b/docs/cross-language.md
@@ -0,0 +1,138 @@
+# Cross-Language Support
+
+Levels defines a single binary format that can be read and written from C#, Python, TypeScript, and C++. A shared `.fbs` schema file ensures all languages produce byte-identical output.
+
+## Shared Schema
+
+The canonical schema lives at `cross-language/schema.fbs`:
+
+```fbs
+namespace Levels.Schema;
+
+struct Record {
+ observed_time:int64;
+ write_timestamp:int64;
+ price_stream_id:int64;
+ price:int64;
+ quantity:int64;
+ _reserved:uint16;
+ record_type:uint8;
+ record_side:uint8;
+ sequence:uint32;
+ level:uint16;
+ flags:uint16;
+ crc32:uint32;
+ order_id:byte[32];
+}
+```
+
+## Code Generation
+
+Generate readers/writers for each language using the CLI:
+
+```bash
+# Python
+dotnet run --project src/Levels.Cli -- codegen cross-language/schema.fbs \
+ --output cross-language/python --language python
+
+# TypeScript
+dotnet run --project src/Levels.Cli -- codegen cross-language/schema.fbs \
+ --output cross-language/typescript --language typescript
+
+# C++
+dotnet run --project src/Levels.Cli -- codegen cross-language/schema.fbs \
+ --output cross-language/cpp --language cpp
+```
+
+## Generated Output by Language
+
+### Python (`record.py`)
+
+Generated module containing:
+- Constants: `HEADER_SIZE`, `FOOTER_SIZE`, `CORE_RECORD_SIZE`, `RECORD_SIZE`, `EXTENSION_SIZE`, `FORMAT_VERSION`, `SCHEMA_ID`
+- `EXTENSION_FIELDS` list: `[(name, offset_from_core, size), ...]`
+- `compute_crc32()` -- CRC32 (IEEE polynomial) over core[0:52] + extension bytes
+- `pack_header()` -- writes 128-byte header using `struct.pack()`
+- `pack_record()` -- writes core + extension fields with CRC
+- `pack_footer()` -- writes 64-byte footer
+
+Uses `struct.pack()` with format string `
+
+
+
+```
+
+The Roslyn generator emits `Record`, `RecordAccessor`, and `RecordWriter` types at build time.
+
+**Option B: CLI**
+
+```bash
+dotnet run --project src/Levels.Cli -- codegen schema.fbs --output ./Generated
+```
+
+This produces `Record.g.cs` containing the same types.
+
+### 3. Register Services
+
+```csharp
+using Levels.Hosting;
+
+var builder = Host.CreateDefaultBuilder(args)
+ .ConfigureServices(services =>
+ {
+ services.AddLevels(opts =>
+ {
+ opts.DataPath = "/data/levels";
+ opts.PriceScale = 2; // e.g. 10350042 = $103,500.42
+ opts.QuantityScale = 8; // e.g. satoshi precision
+ });
+
+ services.AddLevelsDataSink();
+ services.AddHostedService();
+ });
+```
+
+`AddLevels()` automatically reads `SchemaId` and `RecordSize` from the generated `ISchemaDescriptor` implementation. It registers:
+
+- `LevelsOptions` (singleton)
+- `FileIndex` (loaded from disk on startup)
+- `QueryLayer` and `QueryConfig`
+- `DataFlowBus` with handlers (file index sync, compaction, promotion)
+- `SinkConfig` and `PriceStreamSink`
+- OpenTelemetry (if enabled)
+
+`AddLevelsDataSink()` registers `IDataSink` backed by a `DataSinkAdapter` that converts your typed records to `RawMarketEvent`.
+
+### 4. Write Records
+
+Inject `IDataSink` and call `WriteAsync`:
+
+```csharp
+sealed class MyIngestionService : BackgroundService
+{
+ private readonly IDataSink _sink;
+
+ public MyIngestionService(IDataSink sink) => _sink = sink;
+
+ protected override async Task ExecuteAsync(CancellationToken ct)
+ {
+ await Task.Yield(); // let the host finish starting
+
+ var orderId = new byte[32];
+ Encoding.UTF8.GetBytes("order-001", orderId);
+
+ await _sink.WriteAsync(new Record(
+ Venue: "binance",
+ Symbol: "BTCUSDT",
+ ObservedTime: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ Price: 10_350_042, // $103,500.42 with PriceScale=2
+ Quantity: 1_50000000, // 1.5 BTC with QuantityScale=8
+ RecordType: RecordType.Snap,
+ RecordSide: RecordSide.Bid,
+ OrderId: orderId), ct);
+ }
+}
+```
+
+## Configuration
+
+All options are set via the `Action` callback. See the [Configuration Reference](configuration.md) for the full list.
+
+Key options for embedded mode:
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `DataPath` | required | Directory for data files |
+| `PriceScale` | 0 | Decimal places for price fields |
+| `QuantityScale` | 0 | Decimal places for quantity fields |
+| `RolloverSize` | 256 MB | Max file size before rollover |
+| `RolloverInterval` | 1 hour | Time-based rollover |
+| `BackpressureLimit` | 8192 | Channel capacity before throttling |
+| `EnableCompaction` | true | Automatic RAW -> AGG compaction |
+| `EnablePeriodPromotion` | true | Automatic AGG -> PERIOD promotion |
+
+## Enabling Compaction
+
+Compaction is enabled by default. Configure the window and snapshot intervals:
+
+```csharp
+services.AddLevels(opts =>
+{
+ opts.DataPath = "/data/levels";
+ opts.CompactionWindow = TimeSpan.FromHours(1);
+ opts.SyntheticSnapIntervalDeltas = 1000; // snapshot every 1000 deltas
+ opts.RetentionWindow = TimeSpan.FromDays(7);
+ opts.WindowGracePeriod = TimeSpan.FromMinutes(5);
+});
+```
+
+## OpenTelemetry
+
+Enable metrics and/or tracing:
+
+```csharp
+services.AddLevels(opts =>
+{
+ opts.DataPath = "/data/levels";
+ opts.EnableMetrics = true;
+ opts.EnableTracing = true;
+ opts.OtlpEndpoint = "http://localhost:4317";
+});
+```
+
+The `Levels` meter and activity source are registered automatically. See [Architecture - Observability](architecture.md#observability) for the full metric list.
+
+## Querying Data
+
+Resolve `QueryLayer` from DI to query stored data:
+
+```csharp
+var queryLayer = host.Services.GetRequiredService();
+var fileIndex = host.Services.GetRequiredService();
+
+// List all known streams
+var streams = fileIndex.GetAllStreams();
+
+// Reconstruct L2 orderbook
+var streamId = PriceStreamId.FromVenueSymbol("binance", "BTCUSDT");
+var projection = new OrderbookProjection(queryLayer);
+var l2 = projection.ProjectL2(streamId, startTime: 0, endTime: long.MaxValue);
+```
+
+## Complete Example
+
+See [`samples/CryptoExchangeSample`](../samples/CryptoExchangeSample/) for a full working example with synthetic data, and [`samples/BinanceLiveSample`](../samples/BinanceLiveSample/) for live Binance WebSocket ingestion.
diff --git a/docs/quickstart.md b/docs/quickstart.md
new file mode 100644
index 0000000..35568be
--- /dev/null
+++ b/docs/quickstart.md
@@ -0,0 +1,104 @@
+# Quickstart
+
+Get Levels running in under five minutes.
+
+## Prerequisites
+
+- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (preview)
+
+Verify your installation:
+
+```bash
+dotnet --version # should show 10.0.x
+```
+
+## Build
+
+```bash
+git clone https://github.com/DanielBunting/Levels.git
+cd Levels
+dotnet build
+```
+
+## Run the Crypto Exchange Sample
+
+The simplest way to see Levels in action. This sample generates synthetic orderbook data (BTCUSDT and ETHUSDT) and writes it to disk.
+
+```bash
+dotnet run --project samples/CryptoExchangeSample
+```
+
+Output:
+
+```
+Levels Crypto Exchange Sample
+Data path: /tmp/levels-crypto-sample/a1b2c3d4
+
+[BTCUSDT] Sending initial SNAP (10 bid + 10 ask levels)...
+[BTCUSDT] Streaming 40 DELTA updates...
+ ... 25 events written
+ ... 50 events written
+[ETHUSDT] Sending initial SNAP (10 bid + 10 ask levels)...
+[ETHUSDT] Streaming 40 DELTA updates...
+ ... 75 events written
+ ... 100 events written
+Ingestion complete: 120 events written.
+
+Done. Files written:
+ binance//.raw
+```
+
+## Inspect the Output
+
+Use the CLI to examine the binary files:
+
+```bash
+# Inspect file header and footer
+dotnet run --project src/Levels.Cli -- inspect /tmp/levels-crypto-sample//binance//.raw
+
+# Include individual records
+dotnet run --project src/Levels.Cli -- inspect .raw --records
+```
+
+The inspect command shows the file version, schema ID, price/quantity scales, record count, and time range.
+
+## Export Data
+
+Export the written data to CSV:
+
+```bash
+dotnet run --project src/Levels.Cli -- export /tmp/levels-crypto-sample/ \
+ --venue binance --stream BTCUSDT --format csv --output btcusdt.csv
+```
+
+Supported formats: `csv`, `parquet`, `avro`.
+
+## Run the Binance Live Sample
+
+To stream real market data from Binance (requires internet connectivity):
+
+```bash
+dotnet run --project samples/BinanceLiveSample
+```
+
+Press `Ctrl+C` to stop. The sample connects to the Binance WebSocket API and writes live orderbook updates.
+
+## Run Tests
+
+```bash
+dotnet test
+```
+
+## Run Benchmarks
+
+```bash
+dotnet run --project tests/Levels.Benchmarks --configuration Release
+```
+
+## Next Steps
+
+- [Embedded Mode](embedded-mode.md) -- integrate Levels into your own C# application
+- [Server Mode](server-mode.md) -- run Levels as a standalone TCP server
+- [Web API](web-api.md) -- query data via REST endpoints
+- [Schemas](schemas.md) -- define your own data schema
+- [Configuration](configuration.md) -- tune all options
diff --git a/docs/schemas.md b/docs/schemas.md
new file mode 100644
index 0000000..fdc04b5
--- /dev/null
+++ b/docs/schemas.md
@@ -0,0 +1,247 @@
+# Schemas
+
+Levels uses a FlatBuffers-inspired `.fbs` schema format to define record layouts. A single schema file is the source of truth for the binary format across all languages.
+
+## Schema Format
+
+A schema defines a single `struct` with typed fields:
+
+```fbs
+namespace MyApp.Schema;
+
+struct Record {
+ // Core fields (56 bytes) -- managed by Levels
+ observed_time:int64;
+ write_timestamp:int64;
+ price_stream_id:int64;
+ price:int64;
+ quantity:int64;
+ _reserved:uint16;
+ record_type:uint8;
+ record_side:uint8;
+ sequence:uint32;
+ level:uint16;
+ flags:uint16;
+ crc32:uint32;
+
+ // Extension fields (bytes 56+) -- user-defined
+ order_id:byte[32];
+}
+```
+
+## Core vs Extension Fields
+
+The first 56 bytes are **core fields** with fixed layout. Levels manages these internally (write timestamps, CRC, sequence numbers, etc.). Your code provides `ObservedTime`, `Price`, `Quantity`, `RecordType`, and `RecordSide`.
+
+Fields after `crc32` are **extension fields**. These are serialized by generated `WriteExtension()` code and readable via the generated `RecordAccessor`.
+
+## Type Mappings
+
+| FBS Type | C# | Python | TypeScript | C++ | Size |
+|----------|-----|--------|------------|-----|------|
+| int64 | long | struct `q` | BigInt64 | int64_t | 8 |
+| uint64 | ulong | struct `Q` | BigUint64 | uint64_t | 8 |
+| int32 | int | struct `i` | Int32 | int32_t | 4 |
+| uint32 | uint | struct `I` | Uint32 | uint32_t | 4 |
+| int16 | short | struct `h` | Int16 | int16_t | 2 |
+| uint16 | ushort | struct `H` | Uint16 | uint16_t | 2 |
+| int8 | sbyte | struct `b` | Int8 | int8_t | 1 |
+| uint8 | byte | struct `B` | Uint8 | uint8_t | 1 |
+| byte[N] | byte[] (fixed) | bytes | Buffer | uint8_t[N] | N |
+
+## Schema ID
+
+Each schema has a unique ID computed as the FNV-1a hash of the entire `.fbs` file text. This means any change to the file -- including comments or whitespace -- produces a new schema ID. The ID is:
+
+- Written into every file header (offset 12)
+- Validated during TCP handshake (server mode)
+- Available as `Record.SchemaId` in generated code
+
+## Code Generation
+
+There are two code generation paths:
+
+### Build-Time: Roslyn SourceGen
+
+The `Levels.SourceGen` project is a Roslyn incremental generator that watches `.fbs` files added as `AdditionalFiles`:
+
+```xml
+
+
+
+
+```
+
+Types are generated at compile time with no separate build step.
+
+### CLI: `levels codegen`
+
+Generate code for any supported language:
+
+```bash
+# C# (default)
+dotnet run --project src/Levels.Cli -- codegen schema.fbs --output ./Generated
+
+# Python
+dotnet run --project src/Levels.Cli -- codegen schema.fbs --output ./python --language python
+
+# TypeScript
+dotnet run --project src/Levels.Cli -- codegen schema.fbs --output ./typescript --language typescript
+
+# C++
+dotnet run --project src/Levels.Cli -- codegen schema.fbs --output ./cpp --language cpp
+```
+
+## Generated Types
+
+### Record (C#)
+
+A `record struct` implementing both `ISchemaDescriptor` and `ISchemaEvent`:
+
+```csharp
+public record struct Record(
+ string Venue,
+ string Symbol,
+ long ObservedTime,
+ long Price,
+ long Quantity,
+ RecordType RecordType,
+ RecordSide RecordSide,
+ ReadOnlyMemory OrderId = default) : ISchemaDescriptor, ISchemaEvent
+{
+ public static uint SchemaId => 0xE4E8A928u;
+ public static int RecordSize => 88; // 56 core + 32 extension
+
+ public void WriteExtension(Span destination)
+ {
+ OrderId.Span.CopyTo(destination.Slice(0, 32));
+ }
+}
+```
+
+### RecordAccessor (C#)
+
+A `ref struct` for zero-copy reading from a `ReadOnlySpan`:
+
+```csharp
+public readonly ref struct RecordAccessor
+{
+ public long ObservedTime { get; }
+ public long Price { get; }
+ public long Quantity { get; }
+ public RecordType Type { get; }
+ public RecordSide Side { get; }
+ // ... all fields including extensions
+ public ReadOnlySpan OrderId { get; }
+}
+```
+
+### RecordWriter (C#)
+
+A static helper for writing records directly to a `Span`.
+
+## Interfaces
+
+### ISchemaDescriptor
+
+```csharp
+public interface ISchemaDescriptor
+{
+ static abstract uint SchemaId { get; } // FNV-1a hash
+ static abstract int RecordSize { get; } // total bytes per record
+}
+```
+
+Used by `AddLevels()` to auto-configure schema ID and record size.
+
+### ISchemaEvent
+
+```csharp
+public interface ISchemaEvent
+{
+ string Venue { get; }
+ string Symbol { get; }
+ long ObservedTime { get; }
+ long Price { get; }
+ long Quantity { get; }
+ RecordType RecordType { get; }
+ RecordSide RecordSide { get; }
+ void WriteExtension(Span destination);
+}
+```
+
+Implemented by generated record types. The sink calls `WriteExtension()` to serialize extension fields into the binary record.
+
+## Example Schemas
+
+### Core Only (56 bytes)
+
+No extension fields -- just the standard orderbook record:
+
+```fbs
+namespace Basic;
+
+struct Record {
+ observed_time:int64;
+ write_timestamp:int64;
+ price_stream_id:int64;
+ price:int64;
+ quantity:int64;
+ _reserved:uint16;
+ record_type:uint8;
+ record_side:uint8;
+ sequence:uint32;
+ level:uint16;
+ flags:uint16;
+ crc32:uint32;
+}
+```
+
+### With OrderId (88 bytes)
+
+Adds a 32-byte fixed-size order identifier:
+
+```fbs
+namespace Exchange;
+
+struct Record {
+ observed_time:int64;
+ write_timestamp:int64;
+ price_stream_id:int64;
+ price:int64;
+ quantity:int64;
+ _reserved:uint16;
+ record_type:uint8;
+ record_side:uint8;
+ sequence:uint32;
+ level:uint16;
+ flags:uint16;
+ crc32:uint32;
+ order_id:byte[32];
+}
+```
+
+### With Numeric Extension (64 bytes)
+
+Adds an 8-byte integer field (e.g., Binance's `lastUpdateId`):
+
+```fbs
+namespace Binance;
+
+struct Record {
+ observed_time:int64;
+ write_timestamp:int64;
+ price_stream_id:int64;
+ price:int64;
+ quantity:int64;
+ _reserved:uint16;
+ record_type:uint8;
+ record_side:uint8;
+ sequence:uint32;
+ level:uint16;
+ flags:uint16;
+ crc32:uint32;
+ last_update_id:int64;
+}
+```
diff --git a/docs/server-mode.md b/docs/server-mode.md
new file mode 100644
index 0000000..a71e7ca
--- /dev/null
+++ b/docs/server-mode.md
@@ -0,0 +1,149 @@
+# Server Mode
+
+Run Levels as a standalone TCP server that accepts connections from clients in any language. The server loads a schema plugin at startup and validates incoming data against it.
+
+## When to Use
+
+- You have producers in multiple languages (Python, C++, TypeScript)
+- You want a centralized ingestion point for market data
+- You need to decouple producers from the storage engine
+
+## Configuration
+
+The server is configured via a YAML file. Specify the path with `--config=`, the `LEVELS_CONFIG` environment variable, or default to `levels-server.yaml` in the working directory.
+
+### Full Configuration Reference
+
+```yaml
+# Base directory for all data files
+DataPath: ./data
+
+# TCP port to listen on
+Port: 5050
+
+# Schema plugin configuration
+Schema:
+ # Path to the .NET assembly containing the schema type
+ DllPath: path/to/MySchema.dll
+ # Fully qualified type name implementing ISchemaDescriptor
+ TypeName: MyApp.Schema.Record
+
+# Levels engine options
+Levels:
+ PriceScale: 2
+ QuantityScale: 8
+ RolloverSize: 268435456 # 256 MB
+ RolloverInterval: "01:00:00" # 1 hour
+ BackpressureLimit: 8192
+ EnableCompaction: true
+ EnablePeriodPromotion: true
+ CompactionWindow: "01:00:00" # 1 hour
+
+# OpenTelemetry configuration
+Telemetry:
+ EnableMetrics: false
+ OtlpEndpoint: null
+ EnableTracing: false
+```
+
+### Configuration Defaults
+
+| Field | Default |
+|-------|---------|
+| DataPath | `./data` |
+| Port | `5050` |
+| Levels.PriceScale | `0` |
+| Levels.QuantityScale | `0` |
+| Levels.RolloverSize | `268435456` (256 MB) |
+| Levels.RolloverInterval | `01:00:00` |
+| Levels.BackpressureLimit | `8192` |
+| Levels.EnableCompaction | `true` |
+| Levels.EnablePeriodPromotion | `false` |
+| Levels.CompactionWindow | `01:00:00` |
+| Telemetry.EnableMetrics | `false` |
+| Telemetry.EnableTracing | `false` |
+
+## Schema Plugin
+
+The server loads schema metadata from an external .NET assembly at runtime. The assembly must contain a type implementing `ISchemaDescriptor`:
+
+```csharp
+public interface ISchemaDescriptor
+{
+ static abstract uint SchemaId { get; }
+ static abstract int RecordSize { get; }
+}
+```
+
+Build the schema assembly from your generated code:
+
+```bash
+# Generate the schema code
+dotnet run --project src/Levels.Cli -- codegen schema.fbs --output MySchema/
+
+# Build the schema DLL
+dotnet build MySchema/
+```
+
+The `SchemaPluginLoader` uses reflection to read `SchemaId` and `RecordSize` from the type's static properties. These values are written into file headers and validated during client handshakes.
+
+## Starting the Server
+
+```bash
+# With explicit config path
+dotnet run --project src/Levels.Server -- --config=levels-server.yaml
+
+# With environment variable
+export LEVELS_CONFIG=/etc/levels/server.yaml
+dotnet run --project src/Levels.Server
+```
+
+Output:
+
+```
+Loaded schema: MyApp.Schema.Record (SchemaId=0xE4E8A928, RecordSize=88)
+Levels server starting on port 5050
+```
+
+## Protocol
+
+The server uses a binary frame-based protocol over TCP.
+
+**Handshake:** Clients must send their schema ID during connection. The server validates it matches the loaded schema. Mismatched schemas are rejected.
+
+**Frame format:** Each frame contains a message type byte followed by the payload. The `Levels.Protocol` project defines `FrameReader` and `FrameWriter` for serialization.
+
+**Acknowledgements:** The server sends ACKs every 1000 records or 100ms (whichever comes first), confirming durable writes.
+
+## Client Library
+
+The `Levels.Client` project provides a .NET TCP client:
+
+```csharp
+using Levels.Client;
+
+var client = new LevelsClient("localhost", 5050, schemaId: 0xE4E8A928);
+await client.ConnectAsync();
+
+// Write records as raw bytes
+await client.WriteAsync(recordBytes);
+
+await client.DisconnectAsync();
+```
+
+For non-.NET clients, use the [cross-language code generators](cross-language.md) to produce readers/writers in Python, TypeScript, or C++ that speak the same binary format.
+
+## Export Support
+
+The server registers CSV, Parquet, and Avro export adapters. Query and export operations are available through the connected protocol or by adding the [Web API](web-api.md) alongside the TCP server.
+
+## Infrastructure
+
+The server registers the same Levels infrastructure as embedded mode:
+
+- `FileIndex` -- loaded from disk at startup
+- `QueryLayer` -- resolves queries against the file index
+- `DataFlowBus` -- connects writes to compaction and promotion
+- `PriceStreamSink` -- manages file I/O with rollover
+- `ExportPipeline` -- CSV, Parquet, Avro adapters
+- `TcpServer` -- hosted service managing client connections
diff --git a/docs/web-api.md b/docs/web-api.md
new file mode 100644
index 0000000..3016b4d
--- /dev/null
+++ b/docs/web-api.md
@@ -0,0 +1,132 @@
+# Web API
+
+Run Levels as an ASP.NET Core REST API for querying stored data and exporting to various formats. Includes a Prometheus metrics scraping endpoint.
+
+## When to Use
+
+- You need HTTP access to orderbook projections and data exports
+- You want a Prometheus-compatible metrics endpoint
+- You have existing data on disk and need a query layer
+
+## Configuration
+
+The web host uses standard ASP.NET Core configuration. Set Levels options in `appsettings.json`:
+
+```json
+{
+ "Levels": {
+ "DataPath": "./data",
+ "EnableMetrics": true,
+ "OtlpEndpoint": "http://localhost:4317",
+ "EnableTracing": false
+ }
+}
+```
+
+Or via environment variables:
+
+```bash
+export Levels__DataPath=./data
+export Levels__EnableMetrics=true
+```
+
+## Starting the Web API
+
+```bash
+dotnet run --project src/Levels.Web
+```
+
+By default, the ASP.NET Core host listens on `http://localhost:5000`.
+
+## Endpoints
+
+### Health Check
+
+```
+GET /api/health
+```
+
+Response:
+
+```json
+{
+ "status": "healthy",
+ "timestamp": "2025-01-15T10:30:00Z"
+}
+```
+
+### List Streams
+
+```
+GET /api/streams
+```
+
+Returns all known price streams from the file index.
+
+### L2 Orderbook
+
+```
+GET /api/streams/{venue}/{symbol}/book
+GET /api/streams/{venue}/{symbol}/book?excludeOwner=true
+```
+
+Reconstructs the full L2 orderbook (all bid/ask levels) for the given venue and symbol.
+
+Example:
+
+```
+GET /api/streams/binance/BTCUSDT/book
+```
+
+The optional `excludeOwner` parameter filters out records with the `IsOwner` flag set.
+
+### L1 Orderbook
+
+```
+GET /api/streams/{venue}/{symbol}/book/l1
+GET /api/streams/{venue}/{symbol}/book/l1?excludeOwner=true
+```
+
+Returns only the best bid and best ask.
+
+### Export
+
+```
+GET /api/streams/{venue}/{symbol}/export?format=csv
+GET /api/streams/{venue}/{symbol}/export?format=parquet
+GET /api/streams/{venue}/{symbol}/export?format=avro
+```
+
+Exports all data for the given stream. The response is streamed with the appropriate content type:
+
+| Format | Content-Type | Filename |
+|--------|-------------|----------|
+| csv | `text/csv` | `{symbol}.csv` |
+| parquet | `application/octet-stream` | `{symbol}.parquet` |
+| avro | `application/octet-stream` | `{symbol}.avro` |
+
+The `Content-Disposition` header is set to `attachment` with the filename.
+
+### Prometheus Metrics
+
+```
+GET /metrics
+```
+
+Prometheus-compatible scraping endpoint. Exposes all Levels metrics from the `Levels` OpenTelemetry meter. This endpoint is always available regardless of the `EnableMetrics` option (which controls OTLP export).
+
+## Infrastructure
+
+The web host registers:
+
+- `LevelsOptions` with the non-generic `AddLevels()` overload
+- `FileIndex`, `QueryLayer`, `DataFlowBus`, `PriceStreamSink` (same as embedded mode)
+- `CsvExportAdapter`, `ParquetExportAdapter`, `AvroExportAdapter`
+- `ExportPipeline` (wires query layer to export adapters)
+- OpenTelemetry Prometheus exporter
+
+The API endpoints are mapped via `app.MapLevelsApi()` which registers all routes under `/api`.
+
+## Combining with Server Mode
+
+You can run both the TCP server and REST API in the same process by adding the web endpoints to the server's host builder. The `LevelsEndpoints.MapLevelsApi()` extension method works with any `IEndpointRouteBuilder`.
diff --git a/samples/BinanceLiveSample/BinanceLiveSample.csproj b/samples/BinanceLiveSample/BinanceLiveSample.csproj
new file mode 100644
index 0000000..5addf50
--- /dev/null
+++ b/samples/BinanceLiveSample/BinanceLiveSample.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ preview
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/BinanceLiveSample/BinanceWebSocketService.cs b/samples/BinanceLiveSample/BinanceWebSocketService.cs
new file mode 100644
index 0000000..3c8b92d
--- /dev/null
+++ b/samples/BinanceLiveSample/BinanceWebSocketService.cs
@@ -0,0 +1,157 @@
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Hosting;
+using Levels.Core;
+using Levels.Core.Format;
+
+namespace BinanceLiveSample;
+
+sealed class BinanceWebSocketService : BackgroundService
+{
+ private const string StreamUrl =
+ "wss://stream.binance.com:9443/stream?streams=btcusdt@depth10@1000ms/ethusdt@depth10@1000ms";
+
+ private const int PriceScale = 2;
+ private const int QuantityScale = 8;
+
+ private readonly IDataSink _sink;
+ private readonly IHostApplicationLifetime _lifetime;
+ private readonly TimeSpan _runDuration;
+
+ public BinanceWebSocketService(
+ IDataSink sink,
+ IHostApplicationLifetime lifetime)
+ {
+ _sink = sink;
+ _lifetime = lifetime;
+ _runDuration = TimeSpan.FromSeconds(60);
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await Task.Yield();
+
+ using var timeout = new CancellationTokenSource(_runDuration);
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, timeout.Token);
+ var ct = linked.Token;
+
+ using var ws = new ClientWebSocket();
+ Console.WriteLine($"Connecting to Binance WebSocket...");
+ Console.WriteLine($"URL: {StreamUrl}");
+ Console.WriteLine($"Run duration: {_runDuration.TotalSeconds}s");
+ Console.WriteLine();
+
+ try
+ {
+ await ws.ConnectAsync(new Uri(StreamUrl), stoppingToken);
+ Console.WriteLine("Connected. Receiving live orderbook snapshots...");
+ Console.WriteLine();
+
+ var buffer = new byte[16 * 1024];
+ var messageBuffer = new MemoryStream();
+ var totalRecords = 0;
+
+ while (!ct.IsCancellationRequested)
+ {
+ messageBuffer.SetLength(0);
+
+ WebSocketReceiveResult result;
+ do
+ {
+ result = await ws.ReceiveAsync(buffer, ct);
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ Console.WriteLine("Server closed connection.");
+ _lifetime.StopApplication();
+ return;
+ }
+ messageBuffer.Write(buffer, 0, result.Count);
+ } while (!result.EndOfMessage);
+
+ var json = Encoding.UTF8.GetString(messageBuffer.GetBuffer(), 0, (int)messageBuffer.Length);
+ var msg = JsonSerializer.Deserialize(json);
+ if (msg?.Stream is null || msg.Data is null)
+ continue;
+
+ // Stream name is e.g. "btcusdt@depth10@1000ms"
+ var symbol = msg.Stream.Split('@')[0].ToUpperInvariant();
+ var observedTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var lastUpdateId = msg.Data.LastUpdateId;
+
+ var bidCount = msg.Data.Bids?.Length ?? 0;
+ var askCount = msg.Data.Asks?.Length ?? 0;
+
+ for (var i = 0; i < bidCount; i++)
+ {
+ var level = msg.Data.Bids![i];
+ var price = ScaledLong.Parse(level[0], PriceScale);
+ var qty = ScaledLong.Parse(level[1], QuantityScale);
+
+ await _sink.WriteAsync(new BinanceRecord(
+ "binance", symbol, observedTime, price, qty,
+ RecordType.Snap, RecordSide.Bid,
+ LastUpdateId: lastUpdateId), ct);
+ }
+
+ for (var i = 0; i < askCount; i++)
+ {
+ var level = msg.Data.Asks![i];
+ var price = ScaledLong.Parse(level[0], PriceScale);
+ var qty = ScaledLong.Parse(level[1], QuantityScale);
+
+ await _sink.WriteAsync(new BinanceRecord(
+ "binance", symbol, observedTime, price, qty,
+ RecordType.Snap, RecordSide.Ask,
+ LastUpdateId: lastUpdateId), ct);
+ }
+
+ totalRecords += bidCount + askCount;
+
+ var bestBid = bidCount > 0 ? msg.Data.Bids![0][0] : "N/A";
+ var bestAsk = askCount > 0 ? msg.Data.Asks![0][0] : "N/A";
+ Console.WriteLine(
+ $"[{symbol}] {bidCount} bids + {askCount} asks | " +
+ $"best bid={bestBid} ask={bestAsk} | total records: {totalRecords}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine($"Duration elapsed. Total records written: {totalRecords}");
+
+ if (ws.State == WebSocketState.Open)
+ {
+ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", CancellationToken.None);
+ }
+ }
+ catch (OperationCanceledException) when (timeout.IsCancellationRequested)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Duration elapsed. Shutting down...");
+
+ if (ws.State == WebSocketState.Open)
+ {
+ try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Done", CancellationToken.None); }
+ catch { /* best effort */ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine();
+ Console.WriteLine($"Error: {ex.Message}");
+ }
+ finally
+ {
+ _lifetime.StopApplication();
+ }
+ }
+
+ private sealed record CombinedStreamMessage(
+ [property: JsonPropertyName("stream")] string? Stream,
+ [property: JsonPropertyName("data")] DepthData? Data);
+
+ private sealed record DepthData(
+ [property: JsonPropertyName("lastUpdateId")] long LastUpdateId,
+ [property: JsonPropertyName("bids")] string[][]? Bids,
+ [property: JsonPropertyName("asks")] string[][]? Asks);
+}
diff --git a/samples/BinanceLiveSample/Generated/Record.g.cs b/samples/BinanceLiveSample/Generated/Record.g.cs
new file mode 100644
index 0000000..22be3c5
--- /dev/null
+++ b/samples/BinanceLiveSample/Generated/Record.g.cs
@@ -0,0 +1,121 @@
+//
+#nullable enable
+
+using System;
+using System.Buffers.Binary;
+using Levels.Core;
+using Levels.Core.Format;
+
+namespace BinanceLiveSample
+{
+ public record struct BinanceRecord(
+ string Venue,
+ string Symbol,
+ long ObservedTime,
+ long Price,
+ long Quantity,
+ RecordType RecordType,
+ RecordSide RecordSide,
+ long LastUpdateId = 0) : ISchemaDescriptor, ISchemaEvent
+ {
+ public static uint SchemaId => 0xF7F62892u;
+ public static int RecordSize => 64;
+
+ public void WriteExtension(Span destination)
+ {
+ BinaryPrimitives.WriteInt64LittleEndian(destination.Slice(0), LastUpdateId);
+ }
+ }
+
+ public readonly ref struct RecordAccessor
+ {
+ private readonly ReadOnlySpan _span;
+
+ public RecordAccessor(ReadOnlySpan span)
+ {
+ _span = span;
+ }
+
+ public long ObservedTime => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(0));
+ public long WriteTimestamp => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(8));
+ public long PriceStreamId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(16));
+ public long Price => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(24));
+ public long Quantity => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(32));
+ public ushort Reserved => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(40));
+ public byte RecordType => _span[42];
+ public byte RecordSide => _span[43];
+ public uint Sequence => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(44));
+ public ushort Level => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(48));
+ public ushort Flags => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(50));
+ public uint Crc32 => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(52));
+ public long LastUpdateId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(56));
+ }
+
+ public readonly ref struct OrderSnapRecord
+ {
+ private readonly ReadOnlySpan _span;
+
+ public OrderSnapRecord(ReadOnlySpan span)
+ {
+ _span = span;
+ }
+
+ public long ObservedTime => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(0));
+ public long WriteTimestamp => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(8));
+ public long PriceStreamId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(16));
+ public long Price => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(24));
+ public long Quantity => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(32));
+ public ushort Reserved => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(40));
+ public byte RecordType => _span[42];
+ public byte RecordSide => _span[43];
+ public uint Sequence => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(44));
+ public ushort Level => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(48));
+ public ushort Flags => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(50));
+ public uint Crc32 => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(52));
+ public long LastUpdateId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(56));
+ }
+
+ public readonly ref struct OrderDeltaRecord
+ {
+ private readonly ReadOnlySpan _span;
+
+ public OrderDeltaRecord(ReadOnlySpan span)
+ {
+ _span = span;
+ }
+
+ public long ObservedTime => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(0));
+ public long WriteTimestamp => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(8));
+ public long PriceStreamId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(16));
+ public long Price => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(24));
+ public long Quantity => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(32));
+ public ushort Reserved => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(40));
+ public byte RecordType => _span[42];
+ public byte RecordSide => _span[43];
+ public uint Sequence => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(44));
+ public ushort Level => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(48));
+ public ushort Flags => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(50));
+ public uint Crc32 => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(52));
+ public long LastUpdateId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(56));
+ }
+
+ public static class RecordWriter
+ {
+ public static void WriteRecord(Span span, long observedTime, long writeTimestamp, long priceStreamId, long price, long quantity, ushort reserved, byte recordType, byte recordSide, uint sequence, ushort level, ushort flags, uint crc32, long lastUpdateId)
+ {
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(0), observedTime);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(8), writeTimestamp);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(16), priceStreamId);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(24), price);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(32), quantity);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(40), reserved);
+ span[42] = recordType;
+ span[43] = recordSide;
+ BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(44), sequence);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(48), level);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(50), flags);
+ BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(52), crc32);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(56), lastUpdateId);
+ }
+ }
+}
diff --git a/samples/BinanceLiveSample/Program.cs b/samples/BinanceLiveSample/Program.cs
new file mode 100644
index 0000000..15fda26
--- /dev/null
+++ b/samples/BinanceLiveSample/Program.cs
@@ -0,0 +1,39 @@
+using BinanceLiveSample;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Levels.Hosting;
+
+
+var dataPath = Path.Combine(Path.GetTempPath(), "levels-binance-live", Guid.NewGuid().ToString("N")[..8]);
+Directory.CreateDirectory(dataPath);
+
+Console.WriteLine("Levels Binance Live Sample");
+Console.WriteLine($"Data path: {dataPath}");
+Console.WriteLine();
+
+var builder = Host.CreateDefaultBuilder(args)
+ .ConfigureLogging(log => log.SetMinimumLevel(LogLevel.Warning))
+ .ConfigureServices(services =>
+ {
+ services.AddLevels(opts =>
+ {
+ opts.DataPath = dataPath;
+ opts.PriceScale = 2;
+ opts.QuantityScale = 8;
+ opts.RolloverSize = 16 * 1024 * 1024;
+ opts.EnableCompaction = false;
+ opts.EnablePeriodPromotion = false;
+ });
+
+ services.AddLevelsDataSink();
+ services.AddHostedService();
+ });
+
+using var host = builder.Build();
+await host.RunAsync();
+
+Console.WriteLine();
+Console.WriteLine("Done. Files written:");
+foreach (var file in Directory.EnumerateFiles(dataPath, "*", SearchOption.AllDirectories))
+ Console.WriteLine($" {Path.GetRelativePath(dataPath, file)}");
diff --git a/samples/BinanceLiveSample/ScaledLong.cs b/samples/BinanceLiveSample/ScaledLong.cs
new file mode 100644
index 0000000..6c723c0
--- /dev/null
+++ b/samples/BinanceLiveSample/ScaledLong.cs
@@ -0,0 +1,45 @@
+namespace BinanceLiveSample;
+
+internal static class ScaledLong
+{
+ ///
+ /// Converts a decimal string (e.g. "103500.42") to a scaled long (10350042 at scale=2)
+ /// without floating-point arithmetic.
+ ///
+ public static long Parse(ReadOnlySpan value, int scale)
+ {
+ var dotIndex = value.IndexOf('.');
+
+ if (dotIndex < 0)
+ {
+ // No decimal point — multiply by 10^scale
+ var whole = long.Parse(value);
+ for (var i = 0; i < scale; i++)
+ whole *= 10;
+ return whole;
+ }
+
+ var wholePart = long.Parse(value[..dotIndex]);
+ var fracSpan = value[(dotIndex + 1)..];
+ var fracDigits = fracSpan.Length;
+
+ long result = wholePart;
+ for (var i = 0; i < scale; i++)
+ result *= 10;
+
+ if (fracDigits <= scale)
+ {
+ // Fewer fractional digits than scale — parse and pad with zeros
+ var frac = long.Parse(fracSpan);
+ for (var i = 0; i < scale - fracDigits; i++)
+ frac *= 10;
+ return result >= 0 ? result + frac : result - frac;
+ }
+ else
+ {
+ // More fractional digits than scale — truncate
+ var frac = long.Parse(fracSpan[..scale]);
+ return result >= 0 ? result + frac : result - frac;
+ }
+ }
+}
diff --git a/samples/BinanceLiveSample/schema.fbs b/samples/BinanceLiveSample/schema.fbs
new file mode 100644
index 0000000..70caac9
--- /dev/null
+++ b/samples/BinanceLiveSample/schema.fbs
@@ -0,0 +1,15 @@
+namespace BinanceLiveSample;
+
+// Binance partial book depth record.
+// Core fields are managed by Levels. Extensions are venue-specific.
+struct BinanceRecord {
+ venue:string;
+ symbol:string;
+ observed_time:int64;
+ write_timestamp:int64;
+ price:int64;
+ quantity:int64;
+ record_type:uint8;
+ record_side:uint8;
+ last_update_id:int64;
+}
diff --git a/samples/BinancePythonSample/README.md b/samples/BinancePythonSample/README.md
new file mode 100644
index 0000000..c46d249
--- /dev/null
+++ b/samples/BinancePythonSample/README.md
@@ -0,0 +1,90 @@
+# Binance Python Sample
+
+A Python sample that connects to the Binance public websocket, receives BTCUSDT depth-10 orderbook snapshots, and sends records to the Levels TCP server.
+
+This is the Python equivalent of the C# `BinanceLiveSample`.
+
+## Prerequisites
+
+- Python 3.9+
+- .NET 10.0 SDK (for the server and code generation)
+
+## Setup
+
+### 1. Generate the record module from the schema
+
+From the repository root:
+
+```bash
+dotnet run --project src/Levels.Cli -- codegen samples/BinancePythonSample/schema.fbs --language python --output samples/BinancePythonSample/
+```
+
+This produces `record.py` with constants (schema ID, record size) used by the sample.
+
+### 2. Build the schema DLL
+
+The Levels server needs a compiled .NET assembly containing the schema type. Build the BinanceLiveSample (which shares the same schema):
+
+```bash
+dotnet build samples/BinanceLiveSample
+```
+
+### 3. Install Python dependencies
+
+```bash
+cd samples/BinancePythonSample
+pip install -r requirements.txt
+```
+
+Dependencies:
+- `websockets` — async websocket client for the Binance stream
+- `xxhash` — computes the PriceStreamId (xxhash64 of venue + symbol)
+
+## Running
+
+### Step 1: Start the Levels server
+
+From the repository root:
+
+```bash
+dotnet run --project src/Levels.Server -- --config=samples/BinancePythonSample/levels-server.yaml
+```
+
+The server starts on port 5050 and writes data to `/tmp/levels-binance-python/data/`.
+
+### Step 2: Run the Python sample
+
+In a separate terminal:
+
+```bash
+cd samples/BinancePythonSample
+python binance_sample.py
+```
+
+The sample will:
+1. Connect to the Levels server on `localhost:5050` and perform the protocol handshake
+2. Connect to the Binance public websocket (`btcusdt@depth10@1000ms`)
+3. Receive partial orderbook snapshots (10 bid + 10 ask levels) every second
+4. Send each level as a record to the server over the Levels TCP protocol
+5. Run for 60 seconds then disconnect
+
+## How it works
+
+- **Schema** (`schema.fbs`): Defines the record structure with a `last_update_id` extension field (8 bytes), matching the C# BinanceLiveSample schema.
+- **Code generation** (`record.py`): Generated by the Levels CLI. Provides the schema ID and record size constants needed for the protocol handshake.
+- **Server config** (`levels-server.yaml`): Configures the Levels server with the schema DLL, data path, and scaling parameters.
+- **Sample script** (`binance_sample.py`): Connects to Binance, parses JSON depth updates, scales prices (10^2) and quantities (10^8) to integers, packs records into the binary format, and sends them to the server using the Levels framed TCP protocol.
+
+The server handles file management (headers, footers, CRC, rollover, compaction) — the Python client just sends records.
+
+## Protocol
+
+The Levels TCP protocol uses length-prefixed binary frames:
+
+```
+[4 bytes: payload length + 1, LE][1 byte: message type][payload]
+```
+
+1. **Handshake** (0x01): Client sends schema ID and record size; server validates and acks
+2. **WriteRecord** (0x10): Client sends venue, symbol, and raw record bytes
+3. **WriteAck** (0x11): Server periodically acks with accepted/rejected counts
diff --git a/samples/BinancePythonSample/binance_sample.py b/samples/BinancePythonSample/binance_sample.py
new file mode 100644
index 0000000..ede32d9
--- /dev/null
+++ b/samples/BinancePythonSample/binance_sample.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""
+Levels Binance Python Sample
+
+Connects to Binance public websocket, receives BTCUSDT depth10 snapshots,
+and sends records to the Levels TCP server.
+
+Usage:
+ 1. Start the server: dotnet run --project src/Levels.Server -- --config=samples/BinancePythonSample/levels-server.yaml
+ 2. Run this sample: python samples/BinancePythonSample/binance_sample.py
+"""
+
+import asyncio
+import json
+import struct
+import sys
+import time
+
+import websockets
+import xxhash
+
+from record import RECORD_SIZE, SCHEMA_ID
+
+STREAM_URL = "wss://stream.binance.com:9443/stream?streams=btcusdt@depth10@1000ms"
+VENUE = "binance"
+SYMBOL = "BTCUSDT"
+PRICE_SCALE = 2
+QUANTITY_SCALE = 8
+RUN_DURATION = 60 # seconds
+
+SERVER_HOST = "127.0.0.1"
+SERVER_PORT = 5050
+
+# RecordType enum
+RECORD_TYPE_SNAP = 0
+
+# RecordSide enum
+RECORD_SIDE_BID = 0
+RECORD_SIDE_ASK = 1
+
+# Protocol MessageType
+MSG_HANDSHAKE = 0x01
+MSG_HANDSHAKE_ACK = 0x02
+MSG_WRITE_RECORD = 0x10
+MSG_WRITE_ACK = 0x11
+
+# Core record struct format (56 bytes, little-endian)
+# int64×5, uint16, uint8×2, uint32, uint16×2, uint32(crc)
+CORE_FMT = " int:
+ """Compute PriceStreamId as xxhash64(venue + \\x00 + symbol), returned as signed int64."""
+ data = venue.encode("utf-8") + b"\x00" + symbol.encode("utf-8")
+ unsigned = xxhash.xxh64(data).intdigest()
+ # Reinterpret as signed int64 (matches C# unchecked (long) cast)
+ return struct.unpack(" int:
+ """Convert a decimal string to a scaled integer without floating-point arithmetic."""
+ if "." not in value:
+ return int(value) * (10 ** scale)
+ whole_str, frac_str = value.split(".", 1)
+ whole = int(whole_str)
+ result = whole * (10 ** scale)
+ frac_digits = len(frac_str)
+ if frac_digits <= scale:
+ frac = int(frac_str) * (10 ** (scale - frac_digits))
+ else:
+ frac = int(frac_str[:scale])
+ if whole < 0:
+ return result - frac
+ return result + frac
+
+
+def pack_write_record(venue: str, symbol: str, record_bytes: bytes) -> bytes:
+ """Pack a WriteRecord protocol frame: [4-byte len][1-byte type][payload]."""
+ venue_bytes = venue.encode("utf-8")
+ symbol_bytes = symbol.encode("utf-8")
+ payload = bytearray()
+ payload += struct.pack(" bytes:
+ """Pack a raw record (core + extension) without CRC (server computes it)."""
+ core = struct.pack(
+ CORE_FMT,
+ observed_time, write_timestamp, price_stream_id, price, quantity,
+ 0, # _reserved
+ record_type, record_side, sequence, level, flags,
+ 0, # CRC placeholder — server recomputes
+ )
+ ext = struct.pack(EXTENSION_FMT, last_update_id)
+ return core + ext
+
+
+async def read_frame(reader: asyncio.StreamReader) -> tuple[int, bytes]:
+ """Read a protocol frame. Returns (message_type, payload)."""
+ header = await reader.readexactly(5)
+ frame_len = struct.unpack(" 1 else b""
+ return msg_type, payload
+
+
+async def handshake(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
+ """Perform protocol handshake with the Levels server."""
+ payload = struct.pack(" 2 else 0
+ error_msg = payload[3:3 + error_len].decode("utf-8") if error_len else "unknown"
+ raise RuntimeError(f"Handshake failed (status={status}): {error_msg}")
+
+ print("Handshake OK")
+
+
+async def read_acks(reader: asyncio.StreamReader, stop_event: asyncio.Event):
+ """Background task to read WriteAck messages from the server."""
+ try:
+ while not stop_event.is_set():
+ try:
+ msg_type, payload = await asyncio.wait_for(read_frame(reader), timeout=1.0)
+ if msg_type == MSG_WRITE_ACK and len(payload) >= 16:
+ accepted = struct.unpack(" 0:
+ print(f" Server ack: accepted={accepted}, rejected={rejected}")
+ except asyncio.TimeoutError:
+ continue
+ except (asyncio.IncompleteReadError, ConnectionError):
+ pass
+
+
+async def main():
+ price_stream_id = compute_price_stream_id(VENUE, SYMBOL)
+
+ print("Levels Binance Python Sample")
+ print(f"Server: {SERVER_HOST}:{SERVER_PORT}")
+ print(f"Run duration: {RUN_DURATION}s")
+ print()
+
+ # Connect to Levels server
+ print("Connecting to Levels server...")
+ tcp_reader, tcp_writer = await asyncio.open_connection(SERVER_HOST, SERVER_PORT)
+ try:
+ await handshake(tcp_reader, tcp_writer)
+
+ # Start background ack reader
+ stop_event = asyncio.Event()
+ ack_task = asyncio.create_task(read_acks(tcp_reader, stop_event))
+
+ record_count = 0
+ sequence = 0
+ start_time = time.monotonic()
+
+ print(f"Connecting to Binance WebSocket...")
+ async with websockets.connect(STREAM_URL) as ws:
+ print("Connected. Receiving live orderbook snapshots...")
+ print()
+
+ while time.monotonic() - start_time < RUN_DURATION:
+ try:
+ raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
+ except asyncio.TimeoutError:
+ continue
+
+ msg = json.loads(raw)
+ stream = msg.get("stream")
+ data = msg.get("data")
+ if not stream or not data:
+ continue
+
+ symbol = stream.split("@")[0].upper()
+ observed_time = int(time.time() * 1000)
+ last_update_id = data.get("lastUpdateId", 0)
+
+ bids = data.get("bids", [])
+ asks = data.get("asks", [])
+
+ for level_data in bids:
+ price = scaled_long(level_data[0], PRICE_SCALE)
+ qty = scaled_long(level_data[1], QUANTITY_SCALE)
+ write_ts = time.time_ns()
+
+ rec = pack_record_bytes(
+ observed_time, write_ts, price_stream_id,
+ price, qty, RECORD_TYPE_SNAP, RECORD_SIDE_BID,
+ sequence, 0, 0, last_update_id,
+ )
+ frame = pack_write_record(VENUE, symbol, rec)
+ tcp_writer.write(frame)
+ sequence += 1
+ record_count += 1
+
+ for level_data in asks:
+ price = scaled_long(level_data[0], PRICE_SCALE)
+ qty = scaled_long(level_data[1], QUANTITY_SCALE)
+ write_ts = time.time_ns()
+
+ rec = pack_record_bytes(
+ observed_time, write_ts, price_stream_id,
+ price, qty, RECORD_TYPE_SNAP, RECORD_SIDE_ASK,
+ sequence, 0, 0, last_update_id,
+ )
+ frame = pack_write_record(VENUE, symbol, rec)
+ tcp_writer.write(frame)
+ sequence += 1
+ record_count += 1
+
+ await tcp_writer.drain()
+
+ best_bid = bids[0][0] if bids else "N/A"
+ best_ask = asks[0][0] if asks else "N/A"
+ print(
+ f"[{symbol}] {len(bids)} bids + {len(asks)} asks | "
+ f"best bid={best_bid} ask={best_ask} | "
+ f"total records: {record_count}"
+ )
+
+ stop_event.set()
+ await ack_task
+
+ print()
+ print(f"Duration elapsed. Total records sent: {record_count}")
+ finally:
+ tcp_writer.close()
+ await tcp_writer.wait_closed()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/samples/BinancePythonSample/levels-server.yaml b/samples/BinancePythonSample/levels-server.yaml
new file mode 100644
index 0000000..c3e3740
--- /dev/null
+++ b/samples/BinancePythonSample/levels-server.yaml
@@ -0,0 +1,14 @@
+dataPath: /tmp/levels-binance-python/data
+port: 5050
+
+schema:
+ dllPath: samples/BinanceLiveSample/bin/Debug/net10.0/BinanceLiveSample.dll
+ typeName: BinanceLiveSample.BinanceRecord
+
+levels:
+ priceScale: 2
+ quantityScale: 8
+ rolloverSize: 16777216 # 16 MB
+ rolloverInterval: "01:00:00"
+ enableCompaction: false
+ enablePeriodPromotion: false
diff --git a/samples/BinancePythonSample/record.py b/samples/BinancePythonSample/record.py
new file mode 100644
index 0000000..4a31ce5
--- /dev/null
+++ b/samples/BinancePythonSample/record.py
@@ -0,0 +1,99 @@
+#
+# Generated from .fbs schema — do not edit by hand.
+
+from __future__ import annotations
+
+import struct
+import binascii
+
+# ── Constants ──────────────────────────────────────────────────────────────────
+HEADER_SIZE = 128
+FOOTER_SIZE = 64
+CORE_RECORD_SIZE = 56
+RECORD_SIZE = 64
+EXTENSION_SIZE = 8
+FORMAT_VERSION = 2
+SCHEMA_ID = 0xF7F62892
+
+HEADER_MAGIC = b"LEVELS01"
+FOOTER_MAGIC = b"LEVEND01"
+FILE_TYPE_RAW = 0
+
+# Extension fields: (name, offset_from_core, size)
+EXTENSION_FIELDS = [
+ ("last_update_id", 0, 8),
+]
+
+# struct.pack format for the 56-byte core record (little-endian)
+# int64×5, uint16, uint8×2, uint32, uint16×2, uint32
+CORE_FMT = " int:
+ """Compute CRC-32 (IEEE) and return as unsigned 32-bit."""
+ return binascii.crc32(data) & 0xFFFFFFFF
+
+
+def pack_header(price_stream_id: int, created_at: int,
+ price_scale: int, quantity_scale: int,
+ schema_id: int = SCHEMA_ID,
+ file_type: int = FILE_TYPE_RAW,
+ resampled_config_hash: int = 0) -> bytes:
+ buf = bytearray(HEADER_SIZE)
+ buf[0:8] = HEADER_MAGIC
+ struct.pack_into(" bytes:
+ """Pack a full record (core + extension) with computed CRC."""
+ core_data = struct.pack(
+ CORE_FMT,
+ observed_time, write_timestamp, price_stream_id, price, quantity,
+ 0, # _reserved
+ record_type, record_side, sequence, level, flags,
+ 0, # CRC placeholder
+ )
+ ext = extension_bytes if extension_bytes is not None else b'\x00' * EXTENSION_SIZE
+ if len(ext) != EXTENSION_SIZE:
+ raise ValueError(f"Extension bytes must be {EXTENSION_SIZE} bytes, got {len(ext)}")
+ # CRC covers bytes 0..51 + extension bytes (skipping CRC field at 52..55)
+ crc = compute_crc32(core_data[:52] + ext)
+ core_data = struct.pack(
+ CORE_FMT,
+ observed_time, write_timestamp, price_stream_id, price, quantity,
+ 0, record_type, record_side, sequence, level, flags,
+ crc,
+ )
+ return core_data + ext
+
+
+def pack_footer(record_count: int, delta_count: int,
+ first_write_ts: int, last_write_ts: int,
+ first_observed: int, last_observed: int,
+ file_crc: int) -> bytes:
+ buf = bytearray(FOOTER_SIZE)
+ struct.pack_into("=12.0
+xxhash>=3.0
diff --git a/samples/BinancePythonSample/schema.fbs b/samples/BinancePythonSample/schema.fbs
new file mode 100644
index 0000000..70caac9
--- /dev/null
+++ b/samples/BinancePythonSample/schema.fbs
@@ -0,0 +1,15 @@
+namespace BinanceLiveSample;
+
+// Binance partial book depth record.
+// Core fields are managed by Levels. Extensions are venue-specific.
+struct BinanceRecord {
+ venue:string;
+ symbol:string;
+ observed_time:int64;
+ write_timestamp:int64;
+ price:int64;
+ quantity:int64;
+ record_type:uint8;
+ record_side:uint8;
+ last_update_id:int64;
+}
diff --git a/samples/CryptoExchangeSample/CryptoExchangeSample.csproj b/samples/CryptoExchangeSample/CryptoExchangeSample.csproj
new file mode 100644
index 0000000..5addf50
--- /dev/null
+++ b/samples/CryptoExchangeSample/CryptoExchangeSample.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ preview
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/CryptoExchangeSample/Generated/Record.g.cs b/samples/CryptoExchangeSample/Generated/Record.g.cs
new file mode 100644
index 0000000..c1e31e4
--- /dev/null
+++ b/samples/CryptoExchangeSample/Generated/Record.g.cs
@@ -0,0 +1,121 @@
+//
+#nullable enable
+
+using System;
+using System.Buffers.Binary;
+using Levels.Core;
+using Levels.Core.Format;
+
+namespace CryptoExchangeSample
+{
+ public record struct Record(
+ string Venue,
+ string Symbol,
+ long ObservedTime,
+ long Price,
+ long Quantity,
+ RecordType RecordType,
+ RecordSide RecordSide,
+ ReadOnlyMemory OrderId = default) : ISchemaDescriptor, ISchemaEvent
+ {
+ public static uint SchemaId => 0xE4E8A928u;
+ public static int RecordSize => 88;
+
+ public void WriteExtension(Span destination)
+ {
+ OrderId.Span.CopyTo(destination.Slice(0, 32));
+ }
+ }
+
+ public readonly ref struct RecordAccessor
+ {
+ private readonly ReadOnlySpan _span;
+
+ public RecordAccessor(ReadOnlySpan span)
+ {
+ _span = span;
+ }
+
+ public long ObservedTime => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(0));
+ public long WriteTimestamp => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(8));
+ public long PriceStreamId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(16));
+ public long Price => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(24));
+ public long Quantity => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(32));
+ public ushort Reserved => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(40));
+ public byte RecordType => _span[42];
+ public byte RecordSide => _span[43];
+ public uint Sequence => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(44));
+ public ushort Level => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(48));
+ public ushort Flags => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(50));
+ public uint Crc32 => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(52));
+ public ReadOnlySpan OrderId => _span.Slice(56, 32);
+ }
+
+ public readonly ref struct OrderSnapRecord
+ {
+ private readonly ReadOnlySpan _span;
+
+ public OrderSnapRecord(ReadOnlySpan span)
+ {
+ _span = span;
+ }
+
+ public long ObservedTime => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(0));
+ public long WriteTimestamp => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(8));
+ public long PriceStreamId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(16));
+ public long Price => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(24));
+ public long Quantity => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(32));
+ public ushort Reserved => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(40));
+ public byte RecordType => _span[42];
+ public byte RecordSide => _span[43];
+ public uint Sequence => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(44));
+ public ushort Level => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(48));
+ public ushort Flags => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(50));
+ public uint Crc32 => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(52));
+ public ReadOnlySpan OrderId => _span.Slice(56, 32);
+ }
+
+ public readonly ref struct OrderDeltaRecord
+ {
+ private readonly ReadOnlySpan _span;
+
+ public OrderDeltaRecord(ReadOnlySpan span)
+ {
+ _span = span;
+ }
+
+ public long ObservedTime => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(0));
+ public long WriteTimestamp => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(8));
+ public long PriceStreamId => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(16));
+ public long Price => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(24));
+ public long Quantity => BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(32));
+ public ushort Reserved => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(40));
+ public byte RecordType => _span[42];
+ public byte RecordSide => _span[43];
+ public uint Sequence => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(44));
+ public ushort Level => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(48));
+ public ushort Flags => BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(50));
+ public uint Crc32 => BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(52));
+ public ReadOnlySpan OrderId => _span.Slice(56, 32);
+ }
+
+ public static class RecordWriter
+ {
+ public static void WriteRecord(Span span, long observedTime, long writeTimestamp, long priceStreamId, long price, long quantity, ushort reserved, byte recordType, byte recordSide, uint sequence, ushort level, ushort flags, uint crc32, ReadOnlySpan orderId)
+ {
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(0), observedTime);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(8), writeTimestamp);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(16), priceStreamId);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(24), price);
+ BinaryPrimitives.WriteInt64LittleEndian(span.Slice(32), quantity);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(40), reserved);
+ span[42] = recordType;
+ span[43] = recordSide;
+ BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(44), sequence);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(48), level);
+ BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(50), flags);
+ BinaryPrimitives.WriteUInt32LittleEndian(span.Slice(52), crc32);
+ orderId.CopyTo(span.Slice(56, 32));
+ }
+ }
+}
diff --git a/samples/CryptoExchangeSample/Program.cs b/samples/CryptoExchangeSample/Program.cs
new file mode 100644
index 0000000..d3fe729
--- /dev/null
+++ b/samples/CryptoExchangeSample/Program.cs
@@ -0,0 +1,127 @@
+using CryptoExchangeSample;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Levels.Core;
+using Levels.Core.Format;
+using Levels.Hosting;
+
+
+var dataPath = Path.Combine(Path.GetTempPath(), "levels-crypto-sample", Guid.NewGuid().ToString("N")[..8]);
+Directory.CreateDirectory(dataPath);
+
+Console.WriteLine($"Levels Crypto Exchange Sample");
+Console.WriteLine($"Data path: {dataPath}");
+Console.WriteLine();
+
+var builder = Host.CreateDefaultBuilder(args)
+ .ConfigureLogging(log => log.SetMinimumLevel(LogLevel.Warning))
+ .ConfigureServices(services =>
+ {
+ services.AddLevels(opts =>
+ {
+ opts.DataPath = dataPath;
+ opts.PriceScale = 2; // cents precision (e.g. 10350042 = $103,500.42)
+ opts.QuantityScale = 8; // satoshi precision
+ opts.RolloverSize = 16 * 1024 * 1024;
+ opts.EnableCompaction = false;
+ opts.EnablePeriodPromotion = false;
+ });
+
+ services.AddLevelsDataSink();
+ services.AddHostedService();
+ });
+
+using var host = builder.Build();
+await host.RunAsync();
+
+Console.WriteLine();
+Console.WriteLine("Done. Files written:");
+foreach (var file in Directory.EnumerateFiles(dataPath, "*", SearchOption.AllDirectories))
+ Console.WriteLine($" {Path.GetRelativePath(dataPath, file)}");
+
+// ---------------------------------------------------------------------------
+
+sealed class IngestionService : BackgroundService
+{
+ private readonly IDataSink _sink;
+ private readonly IHostApplicationLifetime _lifetime;
+
+ public IngestionService(IDataSink sink, IHostApplicationLifetime lifetime)
+ {
+ _sink = sink;
+ _lifetime = lifetime;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ // Let the host finish starting before we begin writing
+ await Task.Yield();
+
+ var rng = new Random(42);
+ var symbols = new[] { ("BTCUSDT", 10_350_000L), ("ETHUSDT", 380_000L) };
+ var baseTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ var written = 0;
+
+ foreach (var (symbol, basePrice) in symbols)
+ {
+ Console.WriteLine($"[{symbol}] Sending initial SNAP (10 bid + 10 ask levels)...");
+
+ // Initial snapshot: 10 bid levels + 10 ask levels
+ for (var i = 0; i < 10; i++)
+ {
+ var bidPrice = basePrice - (i * 100);
+ var askPrice = basePrice + ((i + 1) * 100);
+ var qty = (long)(rng.NextDouble() * 5_00000000) + 10000000; // 0.1 – 5.1 units
+
+ var orderId = new byte[32];
+ System.Text.Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N")[..12], orderId);
+
+ await _sink.WriteAsync(new Record(
+ "binance", symbol, baseTime, bidPrice, qty,
+ RecordType.Snap, RecordSide.Bid,
+ OrderId: orderId), stoppingToken);
+
+ System.Text.Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N")[..12], orderId);
+
+ await _sink.WriteAsync(new Record(
+ "binance", symbol, baseTime, askPrice, qty,
+ RecordType.Snap, RecordSide.Ask,
+ OrderId: orderId), stoppingToken);
+
+ written += 2;
+ }
+
+ // Stream deltas
+ Console.WriteLine($"[{symbol}] Streaming 40 DELTA updates...");
+ for (var i = 0; i < 40; i++)
+ {
+ baseTime += rng.Next(50, 500);
+ var side = rng.Next(2) == 0 ? RecordSide.Bid : RecordSide.Ask;
+ var offset = rng.Next(-500, 500) * 100L;
+ var price = basePrice + offset;
+ var qty = rng.Next(3) == 0
+ ? 0L // removal
+ : (long)(rng.NextDouble() * 3_00000000) + 1000000;
+
+ var type = qty == 0 ? RecordType.Tombstone : RecordType.Delta;
+
+ var orderId = new byte[32];
+ System.Text.Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N")[..12], orderId);
+
+ await _sink.WriteAsync(new Record(
+ "binance", symbol, baseTime, price, qty,
+ type, side,
+ OrderId: orderId), stoppingToken);
+
+ written++;
+
+ if (written % 25 == 0)
+ Console.WriteLine($" ... {written} events written");
+ }
+ }
+
+ Console.WriteLine($"Ingestion complete: {written} events written.");
+ _lifetime.StopApplication();
+ }
+}
diff --git a/samples/CryptoExchangeSample/schema.fbs b/samples/CryptoExchangeSample/schema.fbs
new file mode 100644
index 0000000..c295f42
--- /dev/null
+++ b/samples/CryptoExchangeSample/schema.fbs
@@ -0,0 +1,15 @@
+namespace CryptoExchangeSample;
+
+// Generic crypto exchange record.
+// Core fields are managed by Levels. Extensions are venue-specific.
+struct Record {
+ venue:string;
+ symbol:string;
+ observed_time:int64;
+ write_timestamp:int64;
+ price:int64;
+ quantity:int64;
+ record_type:uint8;
+ record_side:uint8;
+ order_id:byte[32];
+}
diff --git a/src/Levels.Cli/CodeEmitter.cs b/src/Levels.Cli/CodeEmitter.cs
new file mode 100644
index 0000000..07cb391
--- /dev/null
+++ b/src/Levels.Cli/CodeEmitter.cs
@@ -0,0 +1,206 @@
+using System.Text;
+
+namespace Levels.Cli;
+
+internal static class CodeEmitter
+{
+ public static string Emit(string fbsText)
+ {
+ var model = SchemaModel.FromFbs(fbsText);
+
+ var extensionFields = model.ExtensionFields
+ .Select(f => (f.Name, f.ClrType, f.Offset, f.Size, f.IsFixedArray, f.ArrayLength))
+ .ToList();
+ var allBinaryFields = model.AllFields
+ .Select(f => (f.Name, f.ClrType, f.Offset, f.Size, f.IsFixedArray, f.ArrayLength))
+ .ToList();
+
+ var sb = new StringBuilder();
+ sb.AppendLine("// ");
+ sb.AppendLine("#nullable enable");
+ sb.AppendLine();
+ sb.AppendLine("using System;");
+ sb.AppendLine("using System.Buffers.Binary;");
+ sb.AppendLine("using Levels.Core;");
+ sb.AppendLine("using Levels.Core.Format;");
+ sb.AppendLine();
+ sb.AppendLine($"namespace {model.Namespace}");
+ sb.AppendLine("{");
+
+ EmitRecordStruct(sb, model.StructName, model.SchemaId, model.RecordSize, extensionFields);
+ EmitRefStruct(sb, "RecordAccessor", allBinaryFields);
+ EmitRefStruct(sb, "OrderSnapRecord", allBinaryFields);
+ EmitRefStruct(sb, "OrderDeltaRecord", allBinaryFields);
+ EmitWriteRecord(sb, allBinaryFields);
+
+ sb.AppendLine("}");
+ return sb.ToString();
+ }
+
+ private static void EmitRecordStruct(StringBuilder sb, string structName,
+ uint schemaId, int recordSize,
+ List<(string Name, string ClrType, int Offset, int Size, bool IsFixedArray, int ArrayLength)> extensionFields)
+ {
+ var parameters = new List
+ {
+ "string Venue",
+ "string Symbol",
+ "long ObservedTime",
+ "long Price",
+ "long Quantity",
+ "RecordType RecordType",
+ "RecordSide RecordSide",
+ };
+
+ foreach (var (name, clrType, _, _, isFixedArray, _) in extensionFields)
+ {
+ var paramName = ToPascalCase(name);
+ if (isFixedArray)
+ parameters.Add($"ReadOnlyMemory {paramName} = default");
+ else
+ parameters.Add($"{clrType} {paramName} = 0");
+ }
+
+ sb.AppendLine($" public record struct {structName}(");
+ for (int i = 0; i < parameters.Count; i++)
+ {
+ var suffix = i < parameters.Count - 1 ? "," : ") : ISchemaDescriptor, ISchemaEvent";
+ sb.AppendLine($" {parameters[i]}{suffix}");
+ }
+ sb.AppendLine(" {");
+ sb.AppendLine($" public static uint SchemaId => 0x{schemaId:X8}u;");
+ sb.AppendLine($" public static int RecordSize => {recordSize};");
+ sb.AppendLine();
+
+ sb.AppendLine(" public void WriteExtension(Span destination)");
+ sb.AppendLine(" {");
+ foreach (var (name, clrType, offset, _, isFixedArray, arrayLength) in extensionFields)
+ {
+ var propName = ToPascalCase(name);
+ int relOffset = offset - SchemaModel.CoreRecordSize;
+ if (isFixedArray)
+ {
+ sb.AppendLine($" {propName}.Span.CopyTo(destination.Slice({relOffset}, {arrayLength}));");
+ }
+ else
+ {
+ sb.AppendLine($" {GetWriteStatement(clrType, relOffset, propName, "destination")}");
+ }
+ }
+ sb.AppendLine(" }");
+
+ sb.AppendLine(" }");
+ sb.AppendLine();
+ }
+
+ private static void EmitRefStruct(StringBuilder sb, string typeName,
+ List<(string Name, string ClrType, int Offset, int Size, bool IsFixedArray, int ArrayLength)> fields)
+ {
+ sb.AppendLine($" public readonly ref struct {typeName}");
+ sb.AppendLine(" {");
+ sb.AppendLine(" private readonly ReadOnlySpan _span;");
+ sb.AppendLine();
+ sb.AppendLine($" public {typeName}(ReadOnlySpan span)");
+ sb.AppendLine(" {");
+ sb.AppendLine(" _span = span;");
+ sb.AppendLine(" }");
+ sb.AppendLine();
+
+ foreach (var (name, clrType, offset, _, isFixedArray, arrayLength) in fields)
+ {
+ var propName = ToPascalCase(name);
+ if (isFixedArray)
+ {
+ sb.AppendLine($" public ReadOnlySpan {propName} => _span.Slice({offset}, {arrayLength});");
+ }
+ else
+ {
+ var readExpr = GetReadExpression(clrType, offset);
+ sb.AppendLine($" public {clrType} {propName} => {readExpr};");
+ }
+ }
+
+ sb.AppendLine(" }");
+ sb.AppendLine();
+ }
+
+ private static void EmitWriteRecord(StringBuilder sb,
+ List<(string Name, string ClrType, int Offset, int Size, bool IsFixedArray, int ArrayLength)> fields)
+ {
+ sb.AppendLine(" public static class RecordWriter");
+ sb.AppendLine(" {");
+
+ var parameters = new List { "Span span" };
+ foreach (var (name, clrType, _, _, isFixedArray, _) in fields)
+ {
+ if (isFixedArray)
+ parameters.Add($"ReadOnlySpan {ToCamelCase(name)}");
+ else
+ parameters.Add($"{clrType} {ToCamelCase(name)}");
+ }
+
+ sb.AppendLine($" public static void WriteRecord({string.Join(", ", parameters)})");
+ sb.AppendLine(" {");
+
+ foreach (var (name, clrType, offset, _, isFixedArray, arrayLength) in fields)
+ {
+ var paramName = ToCamelCase(name);
+ if (isFixedArray)
+ {
+ sb.AppendLine($" {paramName}.CopyTo(span.Slice({offset}, {arrayLength}));");
+ }
+ else
+ {
+ sb.AppendLine($" {GetWriteStatement(clrType, offset, paramName)}");
+ }
+ }
+
+ sb.AppendLine(" }");
+ sb.AppendLine(" }");
+ }
+
+ private static string GetReadExpression(string clrType, int offset) => clrType switch
+ {
+ "long" => $"BinaryPrimitives.ReadInt64LittleEndian(_span.Slice({offset}))",
+ "ulong" => $"BinaryPrimitives.ReadUInt64LittleEndian(_span.Slice({offset}))",
+ "int" => $"BinaryPrimitives.ReadInt32LittleEndian(_span.Slice({offset}))",
+ "uint" => $"BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice({offset}))",
+ "short" => $"BinaryPrimitives.ReadInt16LittleEndian(_span.Slice({offset}))",
+ "ushort" => $"BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice({offset}))",
+ "byte" => $"_span[{offset}]",
+ "sbyte" => $"(sbyte)_span[{offset}]",
+ _ => $"default /* unknown: {clrType} */",
+ };
+
+ private static string GetWriteStatement(string clrType, int offset, string param, string target = "span") => clrType switch
+ {
+ "long" => $"BinaryPrimitives.WriteInt64LittleEndian({target}.Slice({offset}), {param});",
+ "ulong" => $"BinaryPrimitives.WriteUInt64LittleEndian({target}.Slice({offset}), {param});",
+ "int" => $"BinaryPrimitives.WriteInt32LittleEndian({target}.Slice({offset}), {param});",
+ "uint" => $"BinaryPrimitives.WriteUInt32LittleEndian({target}.Slice({offset}), {param});",
+ "short" => $"BinaryPrimitives.WriteInt16LittleEndian({target}.Slice({offset}), {param});",
+ "ushort" => $"BinaryPrimitives.WriteUInt16LittleEndian({target}.Slice({offset}), {param});",
+ "byte" => $"{target}[{offset}] = {param};",
+ "sbyte" => $"{target}[{offset}] = (byte){param};",
+ _ => $"// unknown: {clrType}",
+ };
+
+ private static string ToPascalCase(string snake)
+ {
+ var sb = new StringBuilder();
+ bool upper = true;
+ foreach (char c in snake)
+ {
+ if (c == '_') { upper = true; continue; }
+ sb.Append(upper ? char.ToUpperInvariant(c) : c);
+ upper = false;
+ }
+ return sb.ToString();
+ }
+
+ private static string ToCamelCase(string snake)
+ {
+ var p = ToPascalCase(snake);
+ return p.Length == 0 ? p : char.ToLowerInvariant(p[0]) + p[1..];
+ }
+}
diff --git a/src/Levels.Cli/Commands/CodegenCommand.cs b/src/Levels.Cli/Commands/CodegenCommand.cs
new file mode 100644
index 0000000..61ca659
--- /dev/null
+++ b/src/Levels.Cli/Commands/CodegenCommand.cs
@@ -0,0 +1,72 @@
+using Levels.Cli.Emitters;
+
+namespace Levels.Cli.Commands;
+
+internal static class CodegenCommand
+{
+ public static int Run(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: levels codegen [--output ] [--language ]");
+ return 1;
+ }
+
+ var schemaPath = args[0];
+ var outputDir = ".";
+ var language = "csharp";
+
+ for (int i = 1; i < args.Length - 1; i++)
+ {
+ if (args[i] == "--output")
+ outputDir = args[i + 1];
+ else if (args[i] == "--language")
+ language = args[i + 1];
+ }
+
+ if (!File.Exists(schemaPath))
+ {
+ Console.Error.WriteLine($"Schema file not found: {schemaPath}");
+ return 1;
+ }
+
+ var schemaText = File.ReadAllText(schemaPath);
+
+ string code;
+ string outputFile;
+
+ switch (language.ToLowerInvariant())
+ {
+ case "csharp":
+ case "cs":
+ code = CodeEmitter.Emit(schemaText);
+ outputFile = "Record.g.cs";
+ break;
+ case "python":
+ case "py":
+ code = PythonEmitter.Emit(schemaText);
+ outputFile = "record.py";
+ break;
+ case "typescript":
+ case "ts":
+ code = TypeScriptEmitter.Emit(schemaText);
+ outputFile = "record.ts";
+ break;
+ case "cpp":
+ case "c++":
+ code = CppEmitter.Emit(schemaText);
+ outputFile = "record.h";
+ break;
+ default:
+ Console.Error.WriteLine($"Unsupported language: {language}. Use csharp, python, typescript, or cpp.");
+ return 1;
+ }
+
+ Directory.CreateDirectory(outputDir);
+ var outputPath = Path.Combine(outputDir, outputFile);
+ File.WriteAllText(outputPath, code);
+
+ Console.WriteLine($"Generated: {outputPath}");
+ return 0;
+ }
+}
diff --git a/src/Levels.Cli/Commands/CompactCommand.cs b/src/Levels.Cli/Commands/CompactCommand.cs
new file mode 100644
index 0000000..fc10651
--- /dev/null
+++ b/src/Levels.Cli/Commands/CompactCommand.cs
@@ -0,0 +1,74 @@
+using Levels.Compaction;
+
+namespace Levels.Cli.Commands;
+
+internal static class CompactCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: levels compact [--window ]");
+ return 1;
+ }
+
+ var dataPath = args[0];
+ var windowHours = 1.0;
+
+ for (int i = 1; i < args.Length - 1; i++)
+ {
+ if (args[i] == "--window" && double.TryParse(args[i + 1], out var w))
+ windowHours = w;
+ }
+
+ if (!Directory.Exists(dataPath))
+ {
+ Console.Error.WriteLine($"Directory not found: {dataPath}");
+ return 1;
+ }
+
+ var config = new CompactionConfig
+ {
+ DataPath = dataPath,
+ CompactionWindow = TimeSpan.FromHours(windowHours),
+ WindowGracePeriod = TimeSpan.Zero,
+ };
+
+ Console.WriteLine($"Running compaction on {dataPath} with window={windowHours}h...");
+
+ var compaction = new EventSourcingCompaction(config);
+ await compaction.StartAsync(CancellationToken.None);
+
+ // Trigger compaction by scanning sealed files
+ foreach (var rawFile in Directory.GetFiles(dataPath, "*.raw", SearchOption.AllDirectories))
+ {
+ try
+ {
+ using var fs = File.OpenRead(rawFile);
+ var reader = new Levels.Core.IO.BinaryRecordReader(fs);
+ if (reader.Footer is null) continue;
+
+ var sealedInfo = new Levels.Core.SealedFileInfo(
+ rawFile,
+ new Levels.Core.PriceStreamId(reader.Header.PriceStreamId),
+ reader.Footer.Value.RecordCount,
+ reader.Footer.Value.DeltaCount,
+ reader.Footer.Value.FirstObservedTime,
+ reader.Footer.Value.LastObservedTime);
+
+ await compaction.OnFileSealed(sealedInfo, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Warning: Failed to process {rawFile}: {ex.Message}");
+ }
+ }
+
+ // Give time for background processing
+ await Task.Delay(2000);
+ await compaction.StopAsync(CancellationToken.None);
+
+ Console.WriteLine("Compaction complete.");
+ return 0;
+ }
+}
diff --git a/src/Levels.Cli/Commands/ExportCommand.cs b/src/Levels.Cli/Commands/ExportCommand.cs
new file mode 100644
index 0000000..0d712ab
--- /dev/null
+++ b/src/Levels.Cli/Commands/ExportCommand.cs
@@ -0,0 +1,73 @@
+using Levels.Core;
+using Levels.Export;
+using Levels.Query;
+
+namespace Levels.Cli.Commands;
+
+internal static class ExportCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: levels export --venue --stream --format [--output ]");
+ return 1;
+ }
+
+ var dataPath = args[0];
+ string? venue = null;
+ string? symbol = null;
+ var format = "csv";
+ string? outputPath = null;
+
+ for (int i = 1; i < args.Length - 1; i++)
+ {
+ if (args[i] == "--venue") venue = args[i + 1];
+ if (args[i] == "--stream") symbol = args[i + 1];
+ if (args[i] == "--format") format = args[i + 1];
+ if (args[i] == "--output") outputPath = args[i + 1];
+ }
+
+ if (venue is null)
+ {
+ Console.Error.WriteLine("--venue is required");
+ return 1;
+ }
+
+ if (symbol is null)
+ {
+ Console.Error.WriteLine("--stream is required");
+ return 1;
+ }
+
+ if (!Directory.Exists(dataPath))
+ {
+ Console.Error.WriteLine($"Directory not found: {dataPath}");
+ return 1;
+ }
+
+ var fileIndex = new FileIndex();
+ fileIndex.LoadFromDisk(dataPath);
+ var queryLayer = new QueryLayer(fileIndex, new QueryConfig { DataPath = dataPath });
+
+ var adapters = new IExportAdapter[]
+ {
+ new CsvExportAdapter(),
+ new ParquetExportAdapter(),
+ new AvroExportAdapter(),
+ };
+
+ var pipeline = new ExportPipeline(queryLayer, adapters);
+ var streamId = PriceStreamId.FromVenueSymbol(venue, symbol);
+
+ outputPath ??= $"{symbol}.{format}";
+
+ Console.WriteLine($"Exporting stream '{symbol}' to {outputPath} ({format})...");
+
+ await using var output = File.Create(outputPath);
+ await pipeline.ExportAsync(streamId, 0, long.MaxValue, format, output);
+
+ Console.WriteLine($"Export complete: {outputPath}");
+ return 0;
+ }
+}
diff --git a/src/Levels.Cli/Commands/InspectCommand.cs b/src/Levels.Cli/Commands/InspectCommand.cs
new file mode 100644
index 0000000..49292a1
--- /dev/null
+++ b/src/Levels.Cli/Commands/InspectCommand.cs
@@ -0,0 +1,68 @@
+using Levels.Core.Format;
+using Levels.Core.IO;
+
+namespace Levels.Cli.Commands;
+
+internal static class InspectCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: levels inspect [--records]");
+ return 1;
+ }
+
+ var filePath = args[0];
+ var showRecords = args.Contains("--records");
+
+ if (!File.Exists(filePath))
+ {
+ Console.Error.WriteLine($"File not found: {filePath}");
+ return 1;
+ }
+
+ using var fs = File.OpenRead(filePath);
+ var reader = new BinaryRecordReader(fs);
+ var header = reader.Header;
+
+ Console.WriteLine($"File: {filePath}");
+ Console.WriteLine($" Version: {header.Version}");
+ Console.WriteLine($" FileType: {header.FileType}");
+ Console.WriteLine($" PriceStreamId: {header.PriceStreamId}");
+ Console.WriteLine($" SchemaId: 0x{header.SchemaId:X8}");
+ Console.WriteLine($" PriceScale: {header.PriceScale}");
+ Console.WriteLine($" QuantityScale: {header.QuantityScale}");
+ Console.WriteLine($" CreatedAt: {new DateTime(header.CreatedAt, DateTimeKind.Utc):O}");
+ Console.WriteLine($" Partial: {reader.IsPartial}");
+
+ if (reader.Footer is { } footer)
+ {
+ Console.WriteLine($" RecordCount: {footer.RecordCount}");
+ Console.WriteLine($" DeltaCount: {footer.DeltaCount}");
+ Console.WriteLine($" FirstObserved: {footer.FirstObservedTime}");
+ Console.WriteLine($" LastObserved: {footer.LastObservedTime}");
+ Console.WriteLine($" FirstWrite: {footer.FirstWriteTimestamp}");
+ Console.WriteLine($" LastWrite: {footer.LastWriteTimestamp}");
+ Console.WriteLine($" FileCRC: 0x{footer.FileCrc32:X8}");
+ }
+
+ if (showRecords)
+ {
+ Console.WriteLine();
+ Console.WriteLine("Records:");
+ long index = 0;
+ foreach (var record in reader.ReadRecords())
+ {
+ var core = record.Core;
+ Console.WriteLine(
+ $" [{index}] Type={core.Type} Side={core.Side} Price={core.Price} Qty={core.Quantity} " +
+ $"Seq={core.Sequence} Level={core.Level} Flags=0x{core.Flags:X4} " +
+ $"Observed={core.ObservedTime}");
+ index++;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Cli/Commands/ResampleCommand.cs b/src/Levels.Cli/Commands/ResampleCommand.cs
new file mode 100644
index 0000000..510482b
--- /dev/null
+++ b/src/Levels.Cli/Commands/ResampleCommand.cs
@@ -0,0 +1,72 @@
+using Levels.Core;
+using Levels.Core.Format;
+using Levels.Core.IO;
+using Levels.Query;
+
+namespace Levels.Cli.Commands;
+
+internal static class ResampleCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ Console.Error.WriteLine("Usage: levels resample --venue --stream --window ");
+ return 1;
+ }
+
+ var dataPath = args[0];
+ string? venue = null;
+ string? symbol = null;
+ var windowSeconds = 60;
+
+ for (int i = 1; i < args.Length - 1; i++)
+ {
+ if (args[i] == "--venue") venue = args[i + 1];
+ if (args[i] == "--stream") symbol = args[i + 1];
+ if (args[i] == "--window" && int.TryParse(args[i + 1], out var w)) windowSeconds = w;
+ }
+
+ if (venue is null)
+ {
+ Console.Error.WriteLine("--venue is required");
+ return 1;
+ }
+
+ if (symbol is null)
+ {
+ Console.Error.WriteLine("--stream is required");
+ return 1;
+ }
+
+ if (!Directory.Exists(dataPath))
+ {
+ Console.Error.WriteLine($"Directory not found: {dataPath}");
+ return 1;
+ }
+
+ var fileIndex = new FileIndex();
+ fileIndex.LoadFromDisk(dataPath);
+
+ var streamId = PriceStreamId.FromVenueSymbol(venue, symbol);
+ var queryConfig = new QueryConfig { DataPath = dataPath };
+ var queryLayer = new QueryLayer(fileIndex, queryConfig);
+ var entries = queryLayer.Resolve(streamId, 0, long.MaxValue);
+
+ Console.WriteLine($"Found {entries.Count} files for stream '{symbol}' ({streamId.Value})");
+ Console.WriteLine($"Resampling window: {windowSeconds}s");
+
+ long recordCount = 0;
+ foreach (var entry in entries)
+ {
+ using var fs = File.OpenRead(entry.FilePath);
+ var reader = new BinaryRecordReader(fs);
+ foreach (var _ in reader.ReadRecords())
+ recordCount++;
+ }
+
+ Console.WriteLine($"Total records: {recordCount}");
+ Console.WriteLine("Batch resampling complete.");
+ return 0;
+ }
+}
diff --git a/src/Levels.Cli/Emitters/CppEmitter.cs b/src/Levels.Cli/Emitters/CppEmitter.cs
new file mode 100644
index 0000000..d7faf76
--- /dev/null
+++ b/src/Levels.Cli/Emitters/CppEmitter.cs
@@ -0,0 +1,152 @@
+using System.Text;
+
+namespace Levels.Cli.Emitters;
+
+internal static class CppEmitter
+{
+ public static string Emit(string fbsText)
+ {
+ var model = SchemaModel.FromFbs(fbsText);
+ var sb = new StringBuilder();
+
+ sb.AppendLine("// ");
+ sb.AppendLine("// Generated from .fbs schema — do not edit by hand.");
+ sb.AppendLine();
+ sb.AppendLine("#pragma once");
+ sb.AppendLine();
+ sb.AppendLine("#include ");
+ sb.AppendLine("#include ");
+ sb.AppendLine();
+
+ // Constants
+ sb.AppendLine("// ── Constants ──────────────────────────────────────────────────────────────────");
+ sb.AppendLine($"static const int HEADER_SIZE = {SchemaModel.HeaderSize};");
+ sb.AppendLine($"static const int FOOTER_SIZE = {SchemaModel.FooterSize};");
+ sb.AppendLine($"static const int CORE_RECORD_SIZE = {SchemaModel.CoreRecordSize};");
+ sb.AppendLine($"static const int RECORD_SIZE = {model.RecordSize};");
+ sb.AppendLine($"static const int EXTENSION_SIZE = {model.ExtensionSize};");
+ sb.AppendLine($"static const uint16_t FORMAT_VERSION = {SchemaModel.FormatVersion};");
+ sb.AppendLine($"static const uint32_t SCHEMA_ID = 0x{model.SchemaId:X8}u;");
+ sb.AppendLine();
+
+ // Extension field constants
+ if (model.ExtensionFields.Count > 0)
+ {
+ sb.AppendLine("// Extension field offsets (relative to record start)");
+ foreach (var f in model.ExtensionFields)
+ {
+ var upper = f.Name.ToUpperInvariant();
+ sb.AppendLine($"static const int {upper}_OFFSET = {f.Offset};");
+ sb.AppendLine($"static const int {upper}_SIZE = {f.Size};");
+ }
+ sb.AppendLine();
+ }
+
+ // Packed structs
+ sb.AppendLine("// ── Binary layout structs (packed, little-endian) ─────────────────────────────");
+ sb.AppendLine();
+ sb.AppendLine("#pragma pack(push, 1)");
+ sb.AppendLine();
+
+ // FileHeader
+ sb.AppendLine("struct FileHeader {");
+ sb.AppendLine(" uint8_t magic[8]; // \"LEVELS01\"");
+ sb.AppendLine(" uint16_t version; // FORMAT_VERSION");
+ sb.AppendLine(" uint8_t file_type; // 0 = Raw");
+ sb.AppendLine(" uint8_t padding; // 0");
+ sb.AppendLine(" uint32_t schema_id;");
+ sb.AppendLine(" int64_t price_stream_id;");
+ sb.AppendLine(" int64_t created_at;");
+ sb.AppendLine(" int32_t price_scale;");
+ sb.AppendLine(" int32_t quantity_scale;");
+ sb.AppendLine(" uint32_t resampled_config_hash;");
+ sb.AppendLine(" uint16_t record_size; // total bytes per record");
+ sb.AppendLine(" uint8_t reserved[82]; // zeros");
+ sb.AppendLine("};");
+ sb.AppendLine($"static_assert(sizeof(FileHeader) == {SchemaModel.HeaderSize}, \"FileHeader must be {SchemaModel.HeaderSize} bytes\");");
+ sb.AppendLine();
+
+ // CoreRecord struct (always 56 bytes)
+ sb.AppendLine("struct CoreRecord {");
+ sb.AppendLine(" int64_t observed_time; // 0");
+ sb.AppendLine(" int64_t write_timestamp; // 8");
+ sb.AppendLine(" int64_t price_stream_id; // 16");
+ sb.AppendLine(" int64_t price; // 24");
+ sb.AppendLine(" int64_t quantity; // 32");
+ sb.AppendLine(" uint16_t _reserved; // 40");
+ sb.AppendLine(" uint8_t record_type; // 42");
+ sb.AppendLine(" uint8_t record_side; // 43");
+ sb.AppendLine(" uint32_t sequence; // 44");
+ sb.AppendLine(" uint16_t level; // 48");
+ sb.AppendLine(" uint16_t flags; // 50");
+ sb.AppendLine(" uint32_t crc32; // 52");
+ sb.AppendLine("};");
+ sb.AppendLine($"static_assert(sizeof(CoreRecord) == {SchemaModel.CoreRecordSize}, \"CoreRecord must be {SchemaModel.CoreRecordSize} bytes\");");
+ sb.AppendLine();
+
+ // FullRecord struct (core + extensions)
+ if (model.ExtensionFields.Count > 0)
+ {
+ sb.AppendLine("struct FullRecord {");
+ sb.AppendLine(" int64_t observed_time; // 0");
+ sb.AppendLine(" int64_t write_timestamp; // 8");
+ sb.AppendLine(" int64_t price_stream_id; // 16");
+ sb.AppendLine(" int64_t price; // 24");
+ sb.AppendLine(" int64_t quantity; // 32");
+ sb.AppendLine(" uint16_t _reserved; // 40");
+ sb.AppendLine(" uint8_t record_type; // 42");
+ sb.AppendLine(" uint8_t record_side; // 43");
+ sb.AppendLine(" uint32_t sequence; // 44");
+ sb.AppendLine(" uint16_t level; // 48");
+ sb.AppendLine(" uint16_t flags; // 50");
+ sb.AppendLine(" uint32_t crc32; // 52");
+
+ foreach (var f in model.ExtensionFields)
+ {
+ if (f.IsFixedArray)
+ sb.AppendLine($" uint8_t {f.Name}[{f.ArrayLength}]; // {f.Offset}");
+ else
+ sb.AppendLine($" {FbsToCppType(f.FbsType)} {f.Name}; // {f.Offset}");
+ }
+
+ sb.AppendLine("};");
+ sb.AppendLine($"static_assert(sizeof(FullRecord) == {model.RecordSize}, \"FullRecord must be {model.RecordSize} bytes\");");
+ sb.AppendLine();
+ }
+
+ // FileFooter
+ sb.AppendLine("struct FileFooter {");
+ sb.AppendLine(" int64_t record_count; // 0");
+ sb.AppendLine(" int64_t delta_count; // 8");
+ sb.AppendLine(" int64_t first_write_timestamp; // 16");
+ sb.AppendLine(" int64_t last_write_timestamp; // 24");
+ sb.AppendLine(" int64_t first_observed_time; // 32");
+ sb.AppendLine(" int64_t last_observed_time; // 40");
+ sb.AppendLine(" uint32_t file_crc32; // 48");
+ sb.AppendLine(" uint8_t padding[4]; // 52");
+ sb.AppendLine(" uint8_t magic_end[8]; // 56 \"LEVEND01\"");
+ sb.AppendLine("};");
+ sb.AppendLine($"static_assert(sizeof(FileFooter) == {SchemaModel.FooterSize}, \"FileFooter must be {SchemaModel.FooterSize} bytes\");");
+ sb.AppendLine();
+ sb.AppendLine("#pragma pack(pop)");
+ sb.AppendLine();
+
+ return sb.ToString();
+ }
+
+ private static string FbsToCppType(string fbsType) => fbsType switch
+ {
+ "int64" => "int64_t",
+ "uint64" => "uint64_t",
+ "int32" => "int32_t",
+ "uint32" => "uint32_t",
+ "int16" => "int16_t",
+ "uint16" => "uint16_t",
+ "int8" => "int8_t",
+ "uint8" => "uint8_t",
+ "float32" => "float",
+ "float64" => "double",
+ "bool" => "bool",
+ _ => "/* unknown */",
+ };
+}
diff --git a/src/Levels.Cli/Emitters/PythonEmitter.cs b/src/Levels.Cli/Emitters/PythonEmitter.cs
new file mode 100644
index 0000000..c1912c7
--- /dev/null
+++ b/src/Levels.Cli/Emitters/PythonEmitter.cs
@@ -0,0 +1,133 @@
+using System.Text;
+
+namespace Levels.Cli.Emitters;
+
+internal static class PythonEmitter
+{
+ public static string Emit(string fbsText)
+ {
+ var model = SchemaModel.FromFbs(fbsText);
+ var sb = new StringBuilder();
+
+ sb.AppendLine("# ");
+ sb.AppendLine("# Generated from .fbs schema — do not edit by hand.");
+ sb.AppendLine();
+ sb.AppendLine("from __future__ import annotations");
+ sb.AppendLine();
+ sb.AppendLine("import struct");
+ sb.AppendLine("import binascii");
+ sb.AppendLine();
+ sb.AppendLine("# ── Constants ──────────────────────────────────────────────────────────────────");
+ sb.AppendLine($"HEADER_SIZE = {SchemaModel.HeaderSize}");
+ sb.AppendLine($"FOOTER_SIZE = {SchemaModel.FooterSize}");
+ sb.AppendLine($"CORE_RECORD_SIZE = {SchemaModel.CoreRecordSize}");
+ sb.AppendLine($"RECORD_SIZE = {model.RecordSize}");
+ sb.AppendLine($"EXTENSION_SIZE = {model.ExtensionSize}");
+ sb.AppendLine($"FORMAT_VERSION = {SchemaModel.FormatVersion}");
+ sb.AppendLine($"SCHEMA_ID = 0x{model.SchemaId:X8}");
+ sb.AppendLine();
+ sb.AppendLine("HEADER_MAGIC = b\"LEVELS01\"");
+ sb.AppendLine("FOOTER_MAGIC = b\"LEVEND01\"");
+ sb.AppendLine("FILE_TYPE_RAW = 0");
+ sb.AppendLine();
+
+ // Extension fields metadata
+ sb.AppendLine("# Extension fields: (name, offset_from_core, size)");
+ sb.Append("EXTENSION_FIELDS = [");
+ if (model.ExtensionFields.Count > 0)
+ {
+ sb.AppendLine();
+ foreach (var f in model.ExtensionFields)
+ {
+ sb.AppendLine($" (\"{f.Name}\", {f.Offset - SchemaModel.CoreRecordSize}, {f.Size}),");
+ }
+ }
+ sb.AppendLine("]");
+ sb.AppendLine();
+
+ // Core struct format string
+ sb.AppendLine("# struct.pack format for the 56-byte core record (little-endian)");
+ sb.AppendLine("# int64×5, uint16, uint8×2, uint32, uint16×2, uint32");
+ sb.AppendLine("CORE_FMT = \" int:");
+ sb.AppendLine(" \"\"\"Compute CRC-32 (IEEE) and return as unsigned 32-bit.\"\"\"");
+ sb.AppendLine(" return binascii.crc32(data) & 0xFFFFFFFF");
+ sb.AppendLine();
+
+ // pack_header
+ sb.AppendLine();
+ sb.AppendLine("def pack_header(price_stream_id: int, created_at: int,");
+ sb.AppendLine(" price_scale: int, quantity_scale: int,");
+ sb.AppendLine(" schema_id: int = SCHEMA_ID,");
+ sb.AppendLine(" file_type: int = FILE_TYPE_RAW,");
+ sb.AppendLine(" resampled_config_hash: int = 0) -> bytes:");
+ sb.AppendLine(" buf = bytearray(HEADER_SIZE)");
+ sb.AppendLine(" buf[0:8] = HEADER_MAGIC");
+ sb.AppendLine(" struct.pack_into(\" bytes:");
+ sb.AppendLine(" \"\"\"Pack a full record (core + extension) with computed CRC.\"\"\"");
+ sb.AppendLine(" core_data = struct.pack(");
+ sb.AppendLine(" CORE_FMT,");
+ sb.AppendLine(" observed_time, write_timestamp, price_stream_id, price, quantity,");
+ sb.AppendLine(" 0, # _reserved");
+ sb.AppendLine(" record_type, record_side, sequence, level, flags,");
+ sb.AppendLine(" 0, # CRC placeholder");
+ sb.AppendLine(" )");
+ sb.AppendLine(" ext = extension_bytes if extension_bytes is not None else b'\\x00' * EXTENSION_SIZE");
+ sb.AppendLine(" if len(ext) != EXTENSION_SIZE:");
+ sb.AppendLine(" raise ValueError(f\"Extension bytes must be {EXTENSION_SIZE} bytes, got {len(ext)}\")");
+ sb.AppendLine(" # CRC covers bytes 0..51 + extension bytes (skipping CRC field at 52..55)");
+ sb.AppendLine(" crc = compute_crc32(core_data[:52] + ext)");
+ sb.AppendLine(" core_data = struct.pack(");
+ sb.AppendLine(" CORE_FMT,");
+ sb.AppendLine(" observed_time, write_timestamp, price_stream_id, price, quantity,");
+ sb.AppendLine(" 0, record_type, record_side, sequence, level, flags,");
+ sb.AppendLine(" crc,");
+ sb.AppendLine(" )");
+ sb.AppendLine(" return core_data + ext");
+ sb.AppendLine();
+
+ // pack_footer
+ sb.AppendLine();
+ sb.AppendLine("def pack_footer(record_count: int, delta_count: int,");
+ sb.AppendLine(" first_write_ts: int, last_write_ts: int,");
+ sb.AppendLine(" first_observed: int, last_observed: int,");
+ sb.AppendLine(" file_crc: int) -> bytes:");
+ sb.AppendLine(" buf = bytearray(FOOTER_SIZE)");
+ sb.AppendLine(" struct.pack_into(\"");
+ sb.AppendLine("// Generated from .fbs schema — do not edit by hand.");
+ sb.AppendLine();
+ sb.AppendLine("import CRC32 from \"crc-32\";");
+ sb.AppendLine();
+ sb.AppendLine("// ── Constants ──────────────────────────────────────────────────────────────────");
+ sb.AppendLine($"export const HEADER_SIZE = {SchemaModel.HeaderSize};");
+ sb.AppendLine($"export const FOOTER_SIZE = {SchemaModel.FooterSize};");
+ sb.AppendLine($"export const CORE_RECORD_SIZE = {SchemaModel.CoreRecordSize};");
+ sb.AppendLine($"export const RECORD_SIZE = {model.RecordSize};");
+ sb.AppendLine($"export const EXTENSION_SIZE = {model.ExtensionSize};");
+ sb.AppendLine($"export const FORMAT_VERSION = {SchemaModel.FormatVersion};");
+ sb.AppendLine($"export const SCHEMA_ID = 0x{model.SchemaId:X8};");
+ sb.AppendLine();
+ sb.AppendLine("export const HEADER_MAGIC = Buffer.from(\"LEVELS01\", \"ascii\");");
+ sb.AppendLine("export const FOOTER_MAGIC = Buffer.from(\"LEVEND01\", \"ascii\");");
+ sb.AppendLine("export const FILE_TYPE_RAW = 0;");
+ sb.AppendLine();
+
+ // Extension fields metadata
+ sb.AppendLine("// Extension fields: [name, offsetFromCore, size]");
+ sb.AppendLine("export const EXTENSION_FIELDS: [string, number, number][] = [");
+ foreach (var f in model.ExtensionFields)
+ {
+ sb.AppendLine($" [\"{f.Name}\", {f.Offset - SchemaModel.CoreRecordSize}, {f.Size}],");
+ }
+ sb.AppendLine("];");
+ sb.AppendLine();
+
+ // RecordData interface
+ sb.AppendLine("export interface RecordData {");
+ sb.AppendLine(" observedTime: bigint;");
+ sb.AppendLine(" writeTimestamp: bigint;");
+ sb.AppendLine(" priceStreamId: bigint;");
+ sb.AppendLine(" price: bigint;");
+ sb.AppendLine(" quantity: bigint;");
+ sb.AppendLine(" recordType: number;");
+ sb.AppendLine(" recordSide: number;");
+ sb.AppendLine(" sequence: number;");
+ sb.AppendLine(" level: number;");
+ sb.AppendLine(" flags: number;");
+ sb.AppendLine("}");
+ sb.AppendLine();
+
+ // computeCrc32
+ sb.AppendLine("export function computeCrc32(buf: Buffer): number {");
+ sb.AppendLine(" return CRC32.buf(buf) >>> 0;");
+ sb.AppendLine("}");
+ sb.AppendLine();
+
+ // packHeader
+ sb.AppendLine("export function packHeader(");
+ sb.AppendLine(" priceStreamId: bigint,");
+ sb.AppendLine(" createdAt: bigint,");
+ sb.AppendLine(" priceScale: number,");
+ sb.AppendLine(" quantityScale: number,");
+ sb.AppendLine(" schemaId: number = SCHEMA_ID,");
+ sb.AppendLine(" fileType: number = FILE_TYPE_RAW,");
+ sb.AppendLine(" resampledConfigHash: number = 0,");
+ sb.AppendLine("): Buffer {");
+ sb.AppendLine(" const buf = Buffer.alloc(HEADER_SIZE);");
+ sb.AppendLine(" HEADER_MAGIC.copy(buf, 0);");
+ sb.AppendLine(" buf.writeUInt16LE(FORMAT_VERSION, 8);");
+ sb.AppendLine(" buf[10] = fileType;");
+ sb.AppendLine(" buf[11] = 0; // padding");
+ sb.AppendLine(" buf.writeUInt32LE(schemaId, 12);");
+ sb.AppendLine(" buf.writeBigInt64LE(priceStreamId, 16);");
+ sb.AppendLine(" buf.writeBigInt64LE(createdAt, 24);");
+ sb.AppendLine(" buf.writeInt32LE(priceScale, 32);");
+ sb.AppendLine(" buf.writeInt32LE(quantityScale, 36);");
+ sb.AppendLine(" buf.writeUInt32LE(resampledConfigHash, 40);");
+ sb.AppendLine(" buf.writeUInt16LE(RECORD_SIZE, 44);");
+ sb.AppendLine(" return buf;");
+ sb.AppendLine("}");
+ sb.AppendLine();
+
+ // packRecord
+ sb.AppendLine("export function packRecord(rec: RecordData, extensionBytes?: Buffer): Buffer {");
+ sb.AppendLine(" const buf = Buffer.alloc(RECORD_SIZE);");
+ sb.AppendLine(" buf.writeBigInt64LE(rec.observedTime, 0);");
+ sb.AppendLine(" buf.writeBigInt64LE(rec.writeTimestamp, 8);");
+ sb.AppendLine(" buf.writeBigInt64LE(rec.priceStreamId, 16);");
+ sb.AppendLine(" buf.writeBigInt64LE(rec.price, 24);");
+ sb.AppendLine(" buf.writeBigInt64LE(rec.quantity, 32);");
+ sb.AppendLine(" buf.writeUInt16LE(0, 40); // _reserved");
+ sb.AppendLine(" buf[42] = rec.recordType;");
+ sb.AppendLine(" buf[43] = rec.recordSide;");
+ sb.AppendLine(" buf.writeUInt32LE(rec.sequence, 44);");
+ sb.AppendLine(" buf.writeUInt16LE(rec.level, 48);");
+ sb.AppendLine(" buf.writeUInt16LE(rec.flags, 50);");
+ sb.AppendLine(" // Extension bytes (already zero from Buffer.alloc)");
+ sb.AppendLine(" if (extensionBytes) {");
+ sb.AppendLine(" extensionBytes.copy(buf, CORE_RECORD_SIZE, 0, EXTENSION_SIZE);");
+ sb.AppendLine(" }");
+ sb.AppendLine(" // CRC covers bytes 0..51 + bytes 56..RECORD_SIZE");
+ sb.AppendLine(" const crc = computeCrc32(Buffer.concat([buf.subarray(0, 52), buf.subarray(56)]));");
+ sb.AppendLine(" buf.writeUInt32LE(crc, 52);");
+ sb.AppendLine(" return buf;");
+ sb.AppendLine("}");
+ sb.AppendLine();
+
+ // packFooter
+ sb.AppendLine("export function packFooter(");
+ sb.AppendLine(" recordCount: bigint,");
+ sb.AppendLine(" deltaCount: bigint,");
+ sb.AppendLine(" firstWriteTs: bigint,");
+ sb.AppendLine(" lastWriteTs: bigint,");
+ sb.AppendLine(" firstObserved: bigint,");
+ sb.AppendLine(" lastObserved: bigint,");
+ sb.AppendLine(" fileCrc: number,");
+ sb.AppendLine("): Buffer {");
+ sb.AppendLine(" const buf = Buffer.alloc(FOOTER_SIZE);");
+ sb.AppendLine(" buf.writeBigInt64LE(recordCount, 0);");
+ sb.AppendLine(" buf.writeBigInt64LE(deltaCount, 8);");
+ sb.AppendLine(" buf.writeBigInt64LE(firstWriteTs, 16);");
+ sb.AppendLine(" buf.writeBigInt64LE(lastWriteTs, 24);");
+ sb.AppendLine(" buf.writeBigInt64LE(firstObserved, 32);");
+ sb.AppendLine(" buf.writeBigInt64LE(lastObserved, 40);");
+ sb.AppendLine(" buf.writeUInt32LE(fileCrc, 48);");
+ sb.AppendLine(" // 4 bytes padding at 52");
+ sb.AppendLine(" FOOTER_MAGIC.copy(buf, 56);");
+ sb.AppendLine(" return buf;");
+ sb.AppendLine("}");
+ sb.AppendLine();
+
+ return sb.ToString();
+ }
+}
diff --git a/src/Levels.Cli/Levels.Cli.csproj b/src/Levels.Cli/Levels.Cli.csproj
new file mode 100644
index 0000000..6dd9585
--- /dev/null
+++ b/src/Levels.Cli/Levels.Cli.csproj
@@ -0,0 +1,22 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ preview
+ levels
+ true
+ levels
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Levels.Cli/Program.cs b/src/Levels.Cli/Program.cs
new file mode 100644
index 0000000..43587b1
--- /dev/null
+++ b/src/Levels.Cli/Program.cs
@@ -0,0 +1,44 @@
+using Levels.Cli.Commands;
+
+if (args.Length == 0)
+{
+ PrintUsage();
+ return 1;
+}
+
+var command = args[0];
+var commandArgs = args.AsSpan(1).ToArray();
+
+return command switch
+{
+ "inspect" => await InspectCommand.RunAsync(commandArgs),
+ "compact" => await CompactCommand.RunAsync(commandArgs),
+ "codegen" => CodegenCommand.Run(commandArgs),
+ "resample" => await ResampleCommand.RunAsync(commandArgs),
+ "export" => await ExportCommand.RunAsync(commandArgs),
+ "--help" or "-h" => PrintUsage(),
+ _ => PrintUnknown(command),
+};
+
+static int PrintUsage()
+{
+ Console.WriteLine("levels — Levels CLI tool");
+ Console.WriteLine();
+ Console.WriteLine("Usage: levels [options]");
+ Console.WriteLine();
+ Console.WriteLine("Commands:");
+ Console.WriteLine(" inspect Inspect an Levels data file (header, footer, records)");
+ Console.WriteLine(" compact Run compaction on a data directory");
+ Console.WriteLine(" codegen Generate typed accessors from a .fbs schema file");
+ Console.WriteLine(" resample Run batch resampling on existing data");
+ Console.WriteLine(" export Export data to CSV, Parquet, or Avro format");
+ Console.WriteLine();
+ return 0;
+}
+
+static int PrintUnknown(string command)
+{
+ Console.Error.WriteLine($"Unknown command: {command}");
+ Console.Error.WriteLine("Run 'levels --help' for usage.");
+ return 1;
+}
diff --git a/src/Levels.Cli/SchemaModel.cs b/src/Levels.Cli/SchemaModel.cs
new file mode 100644
index 0000000..96f072c
--- /dev/null
+++ b/src/Levels.Cli/SchemaModel.cs
@@ -0,0 +1,171 @@
+using System.Text.RegularExpressions;
+
+namespace Levels.Cli;
+
+internal record BinaryField(
+ string Name,
+ string FbsType,
+ string ClrType,
+ int Offset,
+ int Size,
+ bool IsFixedArray,
+ int ArrayLength);
+
+internal record SchemaModel(
+ string Namespace,
+ string StructName,
+ uint SchemaId,
+ int RecordSize,
+ IReadOnlyList CoreFields,
+ IReadOnlyList ExtensionFields,
+ IReadOnlyList AllFields)
+{
+ public const int CoreRecordSize = 56;
+ public const int HeaderSize = 128;
+ public const int FooterSize = 64;
+ public const int FormatVersion = 2;
+
+ public int ExtensionSize => RecordSize - CoreRecordSize;
+
+ private static readonly HashSet KnownCoreFields = new(StringComparer.Ordinal)
+ {
+ "exchange", "venue", "symbol",
+ "observed_time", "write_timestamp", "price_stream_id",
+ "price", "quantity",
+ "_reserved", "record_type", "record_side",
+ "sequence", "level", "flags", "crc32",
+ };
+
+ private static readonly List CoreBinaryFields =
+ [
+ new("observed_time", "int64", "long", 0, 8, false, 0),
+ new("write_timestamp", "int64", "long", 8, 8, false, 0),
+ new("price_stream_id", "int64", "long", 16, 8, false, 0),
+ new("price", "int64", "long", 24, 8, false, 0),
+ new("quantity", "int64", "long", 32, 8, false, 0),
+ new("_reserved", "uint16", "ushort", 40, 2, false, 0),
+ new("record_type", "uint8", "byte", 42, 1, false, 0),
+ new("record_side", "uint8", "byte", 43, 1, false, 0),
+ new("sequence", "uint32", "uint", 44, 4, false, 0),
+ new("level", "uint16", "ushort", 48, 2, false, 0),
+ new("flags", "uint16", "ushort", 50, 2, false, 0),
+ new("crc32", "uint32", "uint", 52, 4, false, 0),
+ ];
+
+ private static readonly Dictionary TypeMap = new()
+ {
+ ["int64"] = ("long", 8), ["uint64"] = ("ulong", 8),
+ ["int32"] = ("int", 4), ["uint32"] = ("uint", 4),
+ ["int16"] = ("short", 2), ["uint16"] = ("ushort", 2),
+ ["int8"] = ("sbyte", 1), ["uint8"] = ("byte", 1),
+ ["float32"] = ("float", 4), ["float64"] = ("double", 8),
+ ["bool"] = ("bool", 1),
+ ["string"] = ("string", 0),
+ };
+
+ public static SchemaModel FromFbs(string fbsText)
+ {
+ var parsedFields = ParseFbs(fbsText);
+ uint schemaId = ComputeFnv1a(fbsText);
+ var ns = ParseNamespace(fbsText) ?? "Levels.Core.Generated";
+ var structName = ParseStructName(fbsText) ?? "Record";
+
+ var extensionFields = new List();
+ int extOffset = 0;
+ foreach (var f in parsedFields)
+ {
+ if (KnownCoreFields.Contains(f.Name))
+ continue;
+ extensionFields.Add(new BinaryField(f.Name, f.FbsType, f.ClrType,
+ CoreRecordSize + extOffset, f.Size, f.IsFixedArray, f.ArrayLength));
+ extOffset += f.Size;
+ }
+
+ var allFields = new List(CoreBinaryFields);
+ allFields.AddRange(extensionFields);
+
+ int recordSize = allFields.Count > 0
+ ? allFields[^1].Offset + allFields[^1].Size
+ : CoreRecordSize;
+
+ return new SchemaModel(ns, structName, schemaId, recordSize,
+ CoreBinaryFields, extensionFields, allFields);
+ }
+
+ private static List ParseFbs(string fbsText)
+ {
+ var fields = new List();
+ int offset = 0;
+ bool inStruct = false;
+
+ foreach (var rawLine in fbsText.Split('\n'))
+ {
+ var line = rawLine.Trim();
+ var commentIdx = line.IndexOf("//", StringComparison.Ordinal);
+ if (commentIdx >= 0) line = line[..commentIdx].Trim();
+ if (line.Length == 0) continue;
+
+ if (line.StartsWith("struct ") && line.EndsWith("{"))
+ {
+ inStruct = true; offset = 0; continue;
+ }
+ if (inStruct && line == "}") { inStruct = false; continue; }
+ if (!inStruct) continue;
+
+ var fieldLine = line.TrimEnd(';');
+ var colonIdx = fieldLine.IndexOf(':');
+ if (colonIdx < 0) continue;
+
+ var name = fieldLine[..colonIdx].Trim();
+ var fbsType = fieldLine[(colonIdx + 1)..].Trim();
+
+ if (TypeMap.TryGetValue(fbsType, out var mapped))
+ {
+ fields.Add(new BinaryField(name, fbsType, mapped.ClrType, offset, mapped.Size, false, 0));
+ if (mapped.Size > 0)
+ offset += mapped.Size;
+ }
+ else
+ {
+ var arrayMatch = Regex.Match(fbsType, @"^byte\[(\d+)\]$");
+ if (arrayMatch.Success)
+ {
+ int arrayLen = int.Parse(arrayMatch.Groups[1].Value);
+ fields.Add(new BinaryField(name, fbsType, "byte", offset, arrayLen, true, arrayLen));
+ offset += arrayLen;
+ }
+ }
+ }
+
+ return fields;
+ }
+
+ private static string? ParseStructName(string fbsText)
+ {
+ foreach (var rawLine in fbsText.Split('\n'))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("struct ") && line.EndsWith("{"))
+ return line["struct ".Length..^1].Trim();
+ }
+ return null;
+ }
+
+ private static string? ParseNamespace(string fbsText)
+ {
+ foreach (var rawLine in fbsText.Split('\n'))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("namespace ") && line.EndsWith(";"))
+ return line["namespace ".Length..^1].Trim();
+ }
+ return null;
+ }
+
+ public static uint ComputeFnv1a(string text)
+ {
+ uint hash = 2166136261u;
+ foreach (char c in text) { hash ^= (byte)c; hash *= 16777619u; }
+ return hash;
+ }
+}
diff --git a/src/Levels.Client/Commands/BookCommand.cs b/src/Levels.Client/Commands/BookCommand.cs
new file mode 100644
index 0000000..51d1d3f
--- /dev/null
+++ b/src/Levels.Client/Commands/BookCommand.cs
@@ -0,0 +1,49 @@
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class BookCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var venue = ClientHelpers.GetArg(args, "--venue");
+ var symbol = ClientHelpers.GetArg(args, "--symbol");
+
+ if (venue is null || symbol is null)
+ {
+ Console.Error.WriteLine("Usage: levels-client book --server HOST:PORT --venue V --symbol S");
+ return 1;
+ }
+
+ var (client, reader, writer) = await ClientHelpers.ConnectQueryAsync(args);
+ using (client)
+ {
+ await writer.WriteGetBookAsync(venue, symbol, 0, 0, false);
+ await writer.FlushAsync();
+
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f)
+ throw new ProtocolException("Server closed connection");
+
+ if (f.Type == MessageType.ErrorResponse)
+ {
+ Console.Error.WriteLine($"Error: {f.ReadErrorResponse()}");
+ return 1;
+ }
+
+ var response = f.ReadGetBookResponse();
+
+ Console.WriteLine($"Order book for {venue}/{symbol}:");
+ Console.WriteLine();
+ Console.WriteLine(" Asks:");
+ foreach (var (price, qty) in response.Asks.Reverse())
+ Console.WriteLine($" {price,15} | {qty,15}");
+ Console.WriteLine(" --------");
+ Console.WriteLine(" Bids:");
+ foreach (var (price, qty) in response.Bids)
+ Console.WriteLine($" {price,15} | {qty,15}");
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Client/Commands/BookL1Command.cs b/src/Levels.Client/Commands/BookL1Command.cs
new file mode 100644
index 0000000..8217bad
--- /dev/null
+++ b/src/Levels.Client/Commands/BookL1Command.cs
@@ -0,0 +1,50 @@
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class BookL1Command
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var venue = ClientHelpers.GetArg(args, "--venue");
+ var symbol = ClientHelpers.GetArg(args, "--symbol");
+
+ if (venue is null || symbol is null)
+ {
+ Console.Error.WriteLine("Usage: levels-client book-l1 --server HOST:PORT --venue V --symbol S");
+ return 1;
+ }
+
+ var (client, reader, writer) = await ClientHelpers.ConnectQueryAsync(args);
+ using (client)
+ {
+ await writer.WriteGetBookL1Async(venue, symbol, 0, 0, false);
+ await writer.FlushAsync();
+
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f)
+ throw new ProtocolException("Server closed connection");
+
+ if (f.Type == MessageType.ErrorResponse)
+ {
+ Console.Error.WriteLine($"Error: {f.ReadErrorResponse()}");
+ return 1;
+ }
+
+ var response = f.ReadGetBookL1Response();
+
+ Console.WriteLine($"L1 for {venue}/{symbol}:");
+ if (response.BestBid is var (bp, bq))
+ Console.WriteLine($" Best Bid: {bp} x {bq}");
+ else
+ Console.WriteLine(" Best Bid: (none)");
+
+ if (response.BestAsk is var (ap, aq))
+ Console.WriteLine($" Best Ask: {ap} x {aq}");
+ else
+ Console.WriteLine(" Best Ask: (none)");
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Client/Commands/ClientHelpers.cs b/src/Levels.Client/Commands/ClientHelpers.cs
new file mode 100644
index 0000000..404ede9
--- /dev/null
+++ b/src/Levels.Client/Commands/ClientHelpers.cs
@@ -0,0 +1,69 @@
+using System.Net.Sockets;
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class ClientHelpers
+{
+ public static string GetServer(string[] args) => GetArg(args, "--server")
+ ?? Environment.GetEnvironmentVariable("LEVELS_SERVER")
+ ?? "localhost:5050";
+
+ public static string? GetArg(string[] args, string name)
+ {
+ for (int i = 0; i < args.Length - 1; i++)
+ {
+ if (args[i] == name)
+ return args[i + 1];
+ }
+ return null;
+ }
+
+ public static (string Host, int Port) ParseEndpoint(string endpoint)
+ {
+ var parts = endpoint.Split(':');
+ return (parts[0], parts.Length > 1 ? int.Parse(parts[1]) : 5050);
+ }
+
+ ///
+ /// Connects to the server and performs handshake. Returns (stream, reader, writer).
+ /// Caller owns the TcpClient and must dispose it.
+ ///
+ public static async Task<(TcpClient Client, FrameReader Reader, FrameWriter Writer)> ConnectAsync(
+ string[] args, uint schemaId = 0, int recordSize = 0, CancellationToken ct = default)
+ {
+ var (host, port) = ParseEndpoint(GetServer(args));
+ var client = new TcpClient();
+ await client.ConnectAsync(host, port, ct);
+ var stream = client.GetStream();
+ var reader = new FrameReader(stream);
+ var writer = new FrameWriter(stream);
+
+ // Handshake
+ await writer.WriteHandshakeAsync(schemaId, recordSize, ct);
+ await writer.FlushAsync(ct);
+
+ var ackFrame = await reader.ReadFrameAsync(ct);
+ if (ackFrame is not { } af)
+ throw new ProtocolException("Server closed connection during handshake");
+
+ if (af.Type != MessageType.HandshakeAck)
+ throw new ProtocolException($"Expected HandshakeAck, got {af.Type}");
+
+ var ack = af.ReadHandshakeAck();
+ if (ack.Status != HandshakeStatus.Ok)
+ throw new ProtocolException($"Handshake failed: {ack.Error}");
+
+ return (client, reader, writer);
+ }
+
+ ///
+ /// Connects without schema validation (for query/admin commands).
+ /// Sends schema_id=0 and record_size=0.
+ ///
+ public static Task<(TcpClient Client, FrameReader Reader, FrameWriter Writer)> ConnectQueryAsync(
+ string[] args, CancellationToken ct = default)
+ {
+ return ConnectAsync(args, schemaId: 0, recordSize: 0, ct);
+ }
+}
diff --git a/src/Levels.Client/Commands/ExportCommand.cs b/src/Levels.Client/Commands/ExportCommand.cs
new file mode 100644
index 0000000..ffcc84d
--- /dev/null
+++ b/src/Levels.Client/Commands/ExportCommand.cs
@@ -0,0 +1,63 @@
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class ExportCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var venue = ClientHelpers.GetArg(args, "--venue");
+ var symbol = ClientHelpers.GetArg(args, "--symbol");
+ var format = ClientHelpers.GetArg(args, "--format") ?? "csv";
+ var output = ClientHelpers.GetArg(args, "--output");
+
+ if (venue is null || symbol is null)
+ {
+ Console.Error.WriteLine("Usage: levels-client export --server HOST:PORT --venue V --symbol S --format csv [--output file]");
+ return 1;
+ }
+
+ var (client, reader, writer) = await ClientHelpers.ConnectQueryAsync(args);
+ using (client)
+ {
+ await writer.WriteExportAsync(venue, symbol, format, 0, 0);
+ await writer.FlushAsync();
+
+ Stream outputStream = output is not null
+ ? File.Create(output)
+ : Console.OpenStandardOutput();
+
+ try
+ {
+ while (true)
+ {
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f)
+ throw new ProtocolException("Server closed connection during export");
+
+ if (f.Type == MessageType.ErrorResponse)
+ {
+ Console.Error.WriteLine($"Error: {f.ReadErrorResponse()}");
+ return 1;
+ }
+
+ var chunk = f.ReadExportChunk();
+ if (chunk.Data.Length == 0)
+ break; // End marker
+
+ await outputStream.WriteAsync(chunk.Data);
+ }
+ }
+ finally
+ {
+ if (output is not null)
+ await outputStream.DisposeAsync();
+ }
+
+ if (output is not null)
+ Console.WriteLine($"Exported to {output}");
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Client/Commands/HealthCommand.cs b/src/Levels.Client/Commands/HealthCommand.cs
new file mode 100644
index 0000000..03af84e
--- /dev/null
+++ b/src/Levels.Client/Commands/HealthCommand.cs
@@ -0,0 +1,32 @@
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class HealthCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var (client, reader, writer) = await ClientHelpers.ConnectQueryAsync(args);
+ using (client)
+ {
+ await writer.WriteGetHealthAsync();
+ await writer.FlushAsync();
+
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f)
+ throw new ProtocolException("Server closed connection");
+
+ if (f.Type == MessageType.ErrorResponse)
+ {
+ Console.Error.WriteLine($"Error: {f.ReadErrorResponse()}");
+ return 1;
+ }
+
+ var response = f.ReadGetHealthResponse();
+ Console.WriteLine($"Status: {response.Status}");
+ Console.WriteLine($"Timestamp: {response.Timestamp}");
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Client/Commands/SchemaCommand.cs b/src/Levels.Client/Commands/SchemaCommand.cs
new file mode 100644
index 0000000..f1a2fe4
--- /dev/null
+++ b/src/Levels.Client/Commands/SchemaCommand.cs
@@ -0,0 +1,33 @@
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class SchemaCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var (client, reader, writer) = await ClientHelpers.ConnectQueryAsync(args);
+ using (client)
+ {
+ await writer.WriteGetSchemaInfoAsync();
+ await writer.FlushAsync();
+
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f)
+ throw new ProtocolException("Server closed connection");
+
+ if (f.Type == MessageType.ErrorResponse)
+ {
+ Console.Error.WriteLine($"Error: {f.ReadErrorResponse()}");
+ return 1;
+ }
+
+ var response = f.ReadGetSchemaInfoResponse();
+ Console.WriteLine($"Schema ID: 0x{response.SchemaId:X8}");
+ Console.WriteLine($"Record Size: {response.RecordSize} bytes");
+ Console.WriteLine($"Type Name: {response.TypeName}");
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Client/Commands/StreamsCommand.cs b/src/Levels.Client/Commands/StreamsCommand.cs
new file mode 100644
index 0000000..d242673
--- /dev/null
+++ b/src/Levels.Client/Commands/StreamsCommand.cs
@@ -0,0 +1,43 @@
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class StreamsCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var (client, reader, writer) = await ClientHelpers.ConnectQueryAsync(args);
+ using (client)
+ {
+ await writer.WriteGetStreamsAsync();
+ await writer.FlushAsync();
+
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f)
+ throw new ProtocolException("Server closed connection");
+
+ if (f.Type == MessageType.ErrorResponse)
+ {
+ Console.Error.WriteLine($"Error: {f.ReadErrorResponse()}");
+ return 1;
+ }
+
+ var response = f.ReadGetStreamsResponse();
+
+ if (response.Streams.Count == 0)
+ {
+ Console.WriteLine("No streams found.");
+ return 0;
+ }
+
+ Console.WriteLine($"{"Venue",-20} {"Symbol",-15}");
+ Console.WriteLine(new string('-', 35));
+ foreach (var (venue, symbol) in response.Streams)
+ {
+ Console.WriteLine($"{venue,-20} {symbol,-15}");
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Client/Commands/WriteCommand.cs b/src/Levels.Client/Commands/WriteCommand.cs
new file mode 100644
index 0000000..06da465
--- /dev/null
+++ b/src/Levels.Client/Commands/WriteCommand.cs
@@ -0,0 +1,75 @@
+using System.Buffers.Binary;
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class WriteCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var venue = ClientHelpers.GetArg(args, "--venue");
+ var symbol = ClientHelpers.GetArg(args, "--symbol");
+ var priceStr = ClientHelpers.GetArg(args, "--price");
+ var qtyStr = ClientHelpers.GetArg(args, "--qty");
+ var typeStr = ClientHelpers.GetArg(args, "--type") ?? "delta";
+ var sideStr = ClientHelpers.GetArg(args, "--side") ?? "bid";
+ var schemaIdStr = ClientHelpers.GetArg(args, "--schema-id") ?? "0";
+ var recordSizeStr = ClientHelpers.GetArg(args, "--record-size") ?? "56";
+
+ if (venue is null || symbol is null || priceStr is null || qtyStr is null)
+ {
+ Console.Error.WriteLine("Usage: levels-client write --server HOST:PORT --schema-id ID --record-size SIZE --venue V --symbol S --price P --qty Q [--type delta|snap] [--side bid|ask]");
+ return 1;
+ }
+
+ var schemaId = uint.Parse(schemaIdStr, System.Globalization.NumberStyles.HexNumber);
+ var recordSize = int.Parse(recordSizeStr);
+
+ var (client, reader, writer) = await ClientHelpers.ConnectAsync(args, schemaId, recordSize);
+ using (client)
+ {
+ // Build a minimal core record
+ var recordBytes = new byte[recordSize];
+ var observedTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() * 1_000_000;
+ BinaryPrimitives.WriteInt64LittleEndian(recordBytes.AsSpan(0), observedTime);
+ // WriteTimestamp at offset 8 — leave 0, server/writer sets it
+ // PriceStreamId at offset 16 — leave 0, writer computes it
+ BinaryPrimitives.WriteInt64LittleEndian(recordBytes.AsSpan(24), long.Parse(priceStr));
+ BinaryPrimitives.WriteInt64LittleEndian(recordBytes.AsSpan(32), long.Parse(qtyStr));
+ recordBytes[42] = typeStr.ToLowerInvariant() switch
+ {
+ "snap" => 0,
+ "delta" => 1,
+ "tombstone" => 2,
+ _ => 1,
+ };
+ recordBytes[43] = sideStr.ToLowerInvariant() switch
+ {
+ "bid" => 0,
+ "ask" => 1,
+ _ => 2,
+ };
+
+ await writer.WriteRecordAsync(venue, symbol, recordBytes, CancellationToken.None);
+ await writer.FlushAsync();
+
+ // Wait for ack
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f)
+ throw new ProtocolException("Server closed connection");
+
+ if (f.Type == MessageType.WriteAck)
+ {
+ var ack = f.ReadWriteAck();
+ Console.WriteLine($"OK: {ack.Accepted} accepted, {ack.Rejected} rejected");
+ }
+ else if (f.Type == MessageType.ErrorResponse)
+ {
+ Console.Error.WriteLine($"Error: {f.ReadErrorResponse()}");
+ return 1;
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Levels.Client/Commands/WriteStreamCommand.cs b/src/Levels.Client/Commands/WriteStreamCommand.cs
new file mode 100644
index 0000000..df22343
--- /dev/null
+++ b/src/Levels.Client/Commands/WriteStreamCommand.cs
@@ -0,0 +1,104 @@
+using System.Buffers.Binary;
+using System.Text.Json;
+using Levels.Protocol;
+
+namespace Levels.Client.Commands;
+
+internal static class WriteStreamCommand
+{
+ public static async Task RunAsync(string[] args)
+ {
+ var filePath = ClientHelpers.GetArg(args, "--file");
+ var schemaIdStr = ClientHelpers.GetArg(args, "--schema-id") ?? "0";
+ var recordSizeStr = ClientHelpers.GetArg(args, "--record-size") ?? "56";
+
+ var schemaId = uint.Parse(schemaIdStr, System.Globalization.NumberStyles.HexNumber);
+ var recordSize = int.Parse(recordSizeStr);
+
+ var (client, reader, writer) = await ClientHelpers.ConnectAsync(args, schemaId, recordSize);
+ using (client)
+ {
+ // Read acks on background task
+ var ackTask = Task.Run(async () =>
+ {
+ while (true)
+ {
+ var frame = await reader.ReadFrameAsync();
+ if (frame is not { } f) break;
+ if (f.Type == MessageType.WriteAck)
+ {
+ var ack = f.ReadWriteAck();
+ Console.WriteLine($"Progress: {ack.Accepted} accepted, {ack.Rejected} rejected");
+ }
+ }
+ });
+
+ var textReader = filePath is not null ? File.OpenText(filePath) : Console.In;
+ try
+ {
+ string? line;
+ while ((line = await textReader.ReadLineAsync()) is not null)
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ var record = JsonSerializer.Deserialize(line, JsonOptions);
+ if (record is null) continue;
+
+ var recordBytes = new byte[recordSize];
+ BinaryPrimitives.WriteInt64LittleEndian(recordBytes.AsSpan(0), record.ObservedTime);
+ BinaryPrimitives.WriteInt64LittleEndian(recordBytes.AsSpan(24), record.Price);
+ BinaryPrimitives.WriteInt64LittleEndian(recordBytes.AsSpan(32), record.Quantity);
+ recordBytes[42] = ParseType(record.Type);
+ recordBytes[43] = ParseSide(record.Side);
+
+ await writer.WriteRecordAsync(record.Venue ?? "", record.Symbol ?? "", recordBytes);
+ }
+ }
+ finally
+ {
+ if (filePath is not null)
+ textReader.Dispose();
+ }
+
+ await writer.FlushAsync();
+
+ // Close our write side so server sends final ack
+ client.Client.Shutdown(System.Net.Sockets.SocketShutdown.Send);
+ await ackTask;
+ }
+
+ Console.WriteLine("Stream complete.");
+ return 0;
+ }
+
+ private static byte ParseType(string? type) => type?.ToLowerInvariant() switch
+ {
+ "snap" => 0,
+ "delta" => 1,
+ "tombstone" => 2,
+ _ => 1,
+ };
+
+ private static byte ParseSide(string? side) => side?.ToLowerInvariant() switch
+ {
+ "bid" => 0,
+ "ask" => 1,
+ _ => 2,
+ };
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ private sealed class JsonRecord
+ {
+ public string? Venue { get; set; }
+ public string? Symbol { get; set; }
+ public long ObservedTime { get; set; }
+ public long Price { get; set; }
+ public long Quantity { get; set; }
+ public string? Type { get; set; }
+ public string? Side { get; set; }
+ }
+}
diff --git a/src/Levels.Client/Levels.Client.csproj b/src/Levels.Client/Levels.Client.csproj
new file mode 100644
index 0000000..4de76c5
--- /dev/null
+++ b/src/Levels.Client/Levels.Client.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ preview
+ levels-client
+ false
+
+
+
+
+
+
+
diff --git a/src/Levels.Client/Program.cs b/src/Levels.Client/Program.cs
new file mode 100644
index 0000000..5fcc853
--- /dev/null
+++ b/src/Levels.Client/Program.cs
@@ -0,0 +1,48 @@
+using Levels.Client.Commands;
+
+if (args.Length == 0)
+{
+ PrintUsage();
+ return 1;
+}
+
+var command = args[0].ToLowerInvariant();
+var commandArgs = args[1..];
+
+return await (command switch
+{
+ "write" => WriteCommand.RunAsync(commandArgs),
+ "write-stream" => WriteStreamCommand.RunAsync(commandArgs),
+ "streams" => StreamsCommand.RunAsync(commandArgs),
+ "book" => BookCommand.RunAsync(commandArgs),
+ "book-l1" => BookL1Command.RunAsync(commandArgs),
+ "export" => ExportCommand.RunAsync(commandArgs),
+ "health" => HealthCommand.RunAsync(commandArgs),
+ "schema" => SchemaCommand.RunAsync(commandArgs),
+ _ => Task.FromResult(UnknownCommand(command)),
+});
+
+static void PrintUsage()
+{
+ Console.WriteLine("Usage: levels-client [options]");
+ Console.WriteLine();
+ Console.WriteLine("Commands:");
+ Console.WriteLine(" write Write a single record");
+ Console.WriteLine(" write-stream Stream records from JSONL file or stdin");
+ Console.WriteLine(" streams List available streams");
+ Console.WriteLine(" book Get L2 order book snapshot");
+ Console.WriteLine(" book-l1 Get L1 (best bid/ask)");
+ Console.WriteLine(" export Export data to file");
+ Console.WriteLine(" health Check server health");
+ Console.WriteLine(" schema Get schema info");
+ Console.WriteLine();
+ Console.WriteLine("Options:");
+ Console.WriteLine(" --server HOST:PORT Server address (default: LEVELS_SERVER env or localhost:5050)");
+}
+
+static int UnknownCommand(string command)
+{
+ Console.Error.WriteLine($"Unknown command: {command}");
+ PrintUsage();
+ return 1;
+}
diff --git a/src/Levels.Compaction/AggFileWriter.cs b/src/Levels.Compaction/AggFileWriter.cs
new file mode 100644
index 0000000..4a80d2a
--- /dev/null
+++ b/src/Levels.Compaction/AggFileWriter.cs
@@ -0,0 +1,67 @@
+using Levels.Core;
+using Levels.Core.Format;
+using Levels.Core.IO;
+
+namespace Levels.Compaction;
+
+public sealed class AggFileWriter
+{
+ public static async Task WriteAsync(
+ IEnumerable records,
+ string targetPath,
+ PriceStreamId priceStreamId,
+ int priceScale,
+ int quantityScale,
+ IReadOnlyList sourceRawFiles,
+ int recordSize = Constants.CoreRecordSize)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
+
+ await using var fileStream = new FileStream(
+ targetPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ bufferSize: 4096,
+ FileOptions.WriteThrough | FileOptions.Asynchronous);
+
+ await using var writer = await BinaryRecordWriter.CreateAsync(
+ fileStream,
+ FileType.Agg,
+ priceStreamId,
+ priceScale,
+ quantityScale,
+ recordSize: recordSize);
+
+ long syntheticSnapCount = 0;
+
+ foreach (var record in records)
+ {
+ var orderIdBytes = record.OrderId;
+ await writer.WriteRecordAsync(record.Core, orderIdBytes);
+
+ if (record.Core.Type == RecordType.Snap && (record.Core.Flags & Constants.SyntheticSnapFlag) != 0)
+ syntheticSnapCount++;
+ }
+
+ await writer.SealAsync();
+
+ return new AggFileInfo(
+ targetPath,
+ priceStreamId,
+ writer.RecordCount,
+ writer.DeltaCount,
+ syntheticSnapCount,
+ writer.FirstObservedTime,
+ writer.LastObservedTime,
+ sourceRawFiles);
+ }
+
+ public static string ComputeAggPath(string dataPath, string venue, long priceStreamId, long windowStartNanos)
+ {
+ var windowDate = DateTimeOffset.FromUnixTimeMilliseconds(windowStartNanos / 1_000_000);
+ var dir = Path.Combine(dataPath, venue, priceStreamId.ToString());
+ var fileName = $"{windowDate.UtcDateTime:yyyyMMdd_HHmmss}.agg";
+ return Path.Combine(dir, fileName);
+ }
+}
diff --git a/src/Levels.Compaction/ArchivalConfig.cs b/src/Levels.Compaction/ArchivalConfig.cs
new file mode 100644
index 0000000..daa6afc
--- /dev/null
+++ b/src/Levels.Compaction/ArchivalConfig.cs
@@ -0,0 +1,10 @@
+namespace Levels.Compaction;
+
+public sealed class ArchivalConfig
+{
+ public required string DataPath { get; init; }
+ public TimeSpan RawRetention { get; init; } = TimeSpan.FromDays(7);
+ public TimeSpan AggRetention { get; init; } = TimeSpan.FromDays(30);
+ public TimeSpan? PeriodRetention { get; init; } // null = never delete
+ public TimeSpan ScanInterval { get; init; } = TimeSpan.FromHours(1);
+}
diff --git a/src/Levels.Compaction/ArchivalHandler.cs b/src/Levels.Compaction/ArchivalHandler.cs
new file mode 100644
index 0000000..9a2fc01
--- /dev/null
+++ b/src/Levels.Compaction/ArchivalHandler.cs
@@ -0,0 +1,206 @@
+using Microsoft.Extensions.Hosting;
+using Levels.Core;
+using Levels.Core.Diagnostics;
+using Levels.Core.Format;
+using Levels.Core.IO;
+using Levels.DataFlow;
+using Levels.Query;
+
+namespace Levels.Compaction;
+
+public sealed class ArchivalHandler : IDataFlowHandler, IHostedService, IAsyncDisposable
+{
+ private readonly ArchivalConfig _config;
+ private readonly FileIndex _fileIndex;
+ private Task? _backgroundTask;
+ private CancellationTokenSource? _cts;
+
+ public ArchivalHandler(ArchivalConfig config, FileIndex fileIndex)
+ {
+ _config = config;
+ _fileIndex = fileIndex;
+ }
+
+ public ValueTask OnRecordWritten(PriceStreamId stream, RawRecord record, CancellationToken ct)
+ => ValueTask.CompletedTask;
+
+ public ValueTask OnFileSealed(SealedFileInfo file, CancellationToken ct)
+ => ValueTask.CompletedTask;
+
+ public ValueTask OnAggCreated(AggFileInfo file, CancellationToken ct)
+ {
+ // Check if source RAW files exceed retention
+ var nowNanos = WriteTimestamp.Now();
+ var retentionNanos = _config.RawRetention.Ticks * 100;
+
+ foreach (var rawPath in file.SourceRawFiles)
+ {
+ try
+ {
+ using var fs = File.OpenRead(rawPath);
+ var reader = new BinaryRecordReader(fs);
+ if (reader.Footer is null) continue;
+
+ if (nowNanos - reader.Footer.Value.LastObservedTime > retentionNanos)
+ {
+ SoftDelete(rawPath);
+ }
+ }
+ catch { }
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _backgroundTask = RunScanLoopAsync(_cts.Token);
+ return Task.CompletedTask;
+ }
+
+ private async Task RunScanLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await Task.Delay(_config.ScanInterval, ct);
+ ScanAndCleanup();
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
+ catch { }
+ }
+ }
+
+ private void ScanAndCleanup()
+ {
+ if (!Directory.Exists(_config.DataPath)) return;
+
+ var nowNanos = WriteTimestamp.Now();
+ var rawRetentionNanos = _config.RawRetention.Ticks * 100;
+ var aggRetentionNanos = _config.AggRetention.Ticks * 100;
+
+ // Scan RAW files
+ foreach (var rawFile in Directory.GetFiles(_config.DataPath, "*.raw", SearchOption.AllDirectories))
+ {
+ try
+ {
+ using var fs = File.OpenRead(rawFile);
+ if (fs.Length < Constants.HeaderSize + Constants.FooterSize) continue;
+ var reader = new BinaryRecordReader(fs);
+ if (reader.Footer is null) continue;
+
+ if (nowNanos - reader.Footer.Value.LastObservedTime > rawRetentionNanos)
+ {
+ // Check that covering AGG exists
+ var header = reader.Header;
+ var streamId = new PriceStreamId(header.PriceStreamId);
+ if (_fileIndex.HasFile(streamId, FileType.Agg,
+ reader.Footer.Value.FirstObservedTime, reader.Footer.Value.LastObservedTime))
+ {
+ fs.Dispose();
+ SoftDelete(rawFile);
+ }
+ }
+ }
+ catch { }
+ }
+
+ // Scan AGG files
+ if (_config.PeriodRetention is not null) // Only if period retention set, AGGs can be cleaned
+ {
+ foreach (var aggFile in Directory.GetFiles(_config.DataPath, "*.agg", SearchOption.AllDirectories))
+ {
+ try
+ {
+ using var fs = File.OpenRead(aggFile);
+ if (fs.Length < Constants.HeaderSize + Constants.FooterSize) continue;
+ var reader = new BinaryRecordReader(fs);
+ if (reader.Footer is null) continue;
+
+ if (nowNanos - reader.Footer.Value.LastObservedTime > aggRetentionNanos)
+ {
+ var header = reader.Header;
+ var streamId = new PriceStreamId(header.PriceStreamId);
+ if (_fileIndex.HasFile(streamId, FileType.Period,
+ reader.Footer.Value.FirstObservedTime, reader.Footer.Value.LastObservedTime))
+ {
+ fs.Dispose();
+ SoftDelete(aggFile);
+ }
+ }
+ }
+ catch { }
+ }
+ }
+
+ // Clean up trash after 24h
+ CleanupTrash();
+ }
+
+ private void SoftDelete(string filePath)
+ {
+ var dir = Path.GetDirectoryName(filePath)!;
+ var trashDir = Path.Combine(dir, ".trash");
+ Directory.CreateDirectory(trashDir);
+
+ var trashPath = Path.Combine(trashDir, Path.GetFileName(filePath));
+ try
+ {
+ File.Move(filePath, trashPath);
+ _fileIndex.Remove(filePath);
+ StlthLevelsMetrics.ArchivalFilesDeleted.Add(1);
+ }
+ catch { }
+ }
+
+ private void CleanupTrash()
+ {
+ if (!Directory.Exists(_config.DataPath)) return;
+
+ foreach (var trashDir in Directory.GetDirectories(_config.DataPath, ".trash", SearchOption.AllDirectories))
+ {
+ foreach (var file in Directory.GetFiles(trashDir))
+ {
+ try
+ {
+ var lastWrite = File.GetLastWriteTimeUtc(file);
+ if (DateTime.UtcNow - lastWrite > TimeSpan.FromHours(24))
+ {
+ File.Delete(file);
+ }
+ }
+ catch { }
+ }
+
+ // Remove empty trash directories
+ try
+ {
+ if (Directory.GetFiles(trashDir).Length == 0)
+ Directory.Delete(trashDir);
+ }
+ catch { }
+ }
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (_cts is not null)
+ {
+ await _cts.CancelAsync();
+ if (_backgroundTask is not null)
+ {
+ try { await _backgroundTask; }
+ catch (OperationCanceledException) { }
+ }
+ _cts.Dispose();
+ _cts = null;
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await StopAsync(CancellationToken.None);
+ }
+}
diff --git a/src/Levels.Compaction/CompactionConfig.cs b/src/Levels.Compaction/CompactionConfig.cs
new file mode 100644
index 0000000..98896b7
--- /dev/null
+++ b/src/Levels.Compaction/CompactionConfig.cs
@@ -0,0 +1,11 @@
+namespace Levels.Compaction;
+
+public sealed class CompactionConfig
+{
+ public required string DataPath { get; init; }
+ public TimeSpan CompactionWindow { get; init; } = TimeSpan.FromHours(1);
+ public int SyntheticSnapIntervalDeltas { get; init; } = 1000;
+ public TimeSpan? SyntheticSnapIntervalTime { get; init; }
+ public TimeSpan RetentionWindow { get; init; } = TimeSpan.FromDays(7);
+ public TimeSpan WindowGracePeriod { get; init; } = TimeSpan.FromMinutes(5);
+}
diff --git a/src/Levels.Compaction/CompactionWindowKey.cs b/src/Levels.Compaction/CompactionWindowKey.cs
new file mode 100644
index 0000000..c4f407f
--- /dev/null
+++ b/src/Levels.Compaction/CompactionWindowKey.cs
@@ -0,0 +1,18 @@
+using Levels.Core;
+
+namespace Levels.Compaction;
+
+public readonly record struct CompactionWindowKey(
+ PriceStreamId StreamId,
+ string Venue,
+ long WindowStartNanos)
+{
+ public static long ComputeWindowStart(long observedTimeNanos, TimeSpan windowSpan)
+ {
+ var windowNanos = windowSpan.Ticks * 100;
+ if (windowNanos <= 0)
+ throw new ArgumentException("Window span must be positive.", nameof(windowSpan));
+
+ return observedTimeNanos / windowNanos * windowNanos;
+ }
+}
diff --git a/src/Levels.Compaction/EventSourcingCompaction.cs b/src/Levels.Compaction/EventSourcingCompaction.cs
new file mode 100644
index 0000000..52d66ac
--- /dev/null
+++ b/src/Levels.Compaction/EventSourcingCompaction.cs
@@ -0,0 +1,256 @@
+using System.Diagnostics;
+using System.Threading.Channels;
+using Microsoft.Extensions.Hosting;
+using Levels.Core;
+using Levels.Core.Diagnostics;
+using Levels.Core.Format;
+using Levels.Core.IO;
+using Levels.DataFlow;
+
+namespace Levels.Compaction;
+
+public sealed class EventSourcingCompaction : IDataFlowHandler, IHostedService, IAsyncDisposable
+{
+ private readonly CompactionConfig _config;
+ private readonly DataFlowBus? _bus;
+ private readonly Dictionary> _windowFiles = new();
+ private readonly Channel _workChannel;
+ private Task? _backgroundTask;
+ private CancellationTokenSource? _cts;
+
+ public EventSourcingCompaction(CompactionConfig config, DataFlowBus? bus = null)
+ {
+ _config = config;
+ _bus = bus;
+ _workChannel = Channel.CreateUnbounded();
+ }
+
+ public ValueTask OnRecordWritten(PriceStreamId stream, RawRecord record, CancellationToken ct)
+ => ValueTask.CompletedTask;
+
+ public ValueTask OnFileSealed(SealedFileInfo file, CancellationToken ct)
+ {
+ var windowStart = CompactionWindowKey.ComputeWindowStart(
+ file.FirstObservedTime, _config.CompactionWindow);
+ var windowEndNanos = windowStart + _config.CompactionWindow.Ticks * 100;
+
+ // Extract venue from file path: {dataPath}/{venue}/{streamId}/file.raw
+ var venue = ExtractVenue(file.FilePath);
+ var key = new CompactionWindowKey(file.PriceStreamId, venue, windowStart);
+
+ lock (_windowFiles)
+ {
+ if (!_windowFiles.TryGetValue(key, out var files))
+ {
+ files = [];
+ _windowFiles[key] = files;
+ }
+ files.Add(file);
+ }
+
+ // Check if window is closed (wall clock past window end + grace)
+ var nowNanos = WriteTimestamp.Now();
+ if (nowNanos > windowEndNanos + _config.WindowGracePeriod.Ticks * 100)
+ {
+ _workChannel.Writer.TryWrite(key);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask OnAggCreated(AggFileInfo file, CancellationToken ct)
+ => ValueTask.CompletedTask;
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ // Startup recovery: clean up partial AGG files and enqueue missing windows
+ RecoverOnStartup();
+
+ _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _backgroundTask = Task.Run(() => ProcessWorkAsync(_cts.Token), _cts.Token);
+ return Task.CompletedTask;
+ }
+
+ private void RecoverOnStartup()
+ {
+ if (!Directory.Exists(_config.DataPath))
+ return;
+
+ // Delete partial AGG files (no valid footer)
+ foreach (var aggFile in Directory.GetFiles(_config.DataPath, "*.agg", SearchOption.AllDirectories))
+ {
+ try
+ {
+ using var fs = File.OpenRead(aggFile);
+ if (fs.Length < Constants.HeaderSize + Constants.FooterSize)
+ {
+ fs.Dispose();
+ File.Delete(aggFile);
+ continue;
+ }
+
+ var reader = new BinaryRecordReader(fs);
+ if (reader.Footer is null)
+ {
+ fs.Dispose();
+ File.Delete(aggFile);
+ }
+ }
+ catch
+ {
+ try { File.Delete(aggFile); } catch { }
+ }
+ }
+
+ // Find windows with sealed RAW files but no AGG file
+ foreach (var venueDir in Directory.GetDirectories(_config.DataPath))
+ {
+ var venue = Path.GetFileName(venueDir);
+ foreach (var streamDir in Directory.GetDirectories(venueDir))
+ {
+ var streamIdStr = Path.GetFileName(streamDir);
+ if (!long.TryParse(streamIdStr, out var streamIdValue))
+ continue;
+
+ var priceStreamId = new PriceStreamId(streamIdValue);
+ var rawFiles = RawFileLocator.FindSealedFiles(_config.DataPath, venue, streamIdValue);
+ var aggFiles = Directory.GetFiles(streamDir, "*.agg")
+ .Where(f => IsSealedAgg(f))
+ .ToHashSet();
+
+ foreach (var rawFile in rawFiles)
+ {
+ using var fs = File.OpenRead(rawFile);
+ var reader = new BinaryRecordReader(fs);
+ if (reader.Footer is null) continue;
+
+ var windowStart = CompactionWindowKey.ComputeWindowStart(
+ reader.Footer.Value.FirstObservedTime, _config.CompactionWindow);
+ var aggPath = AggFileWriter.ComputeAggPath(
+ _config.DataPath, venue, streamIdValue, windowStart);
+
+ if (!aggFiles.Contains(aggPath))
+ {
+ var key = new CompactionWindowKey(priceStreamId, venue, windowStart);
+ _workChannel.Writer.TryWrite(key);
+ }
+ }
+ }
+ }
+ }
+
+ private static bool IsSealedAgg(string path)
+ {
+ try
+ {
+ using var fs = File.OpenRead(path);
+ if (fs.Length < Constants.HeaderSize + Constants.FooterSize)
+ return false;
+ var reader = new BinaryRecordReader(fs);
+ return reader.Footer is not null;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private async Task ProcessWorkAsync(CancellationToken ct)
+ {
+ var processed = new HashSet();
+
+ await foreach (var key in _workChannel.Reader.ReadAllAsync(ct))
+ {
+ if (!processed.Add(key))
+ continue; // Already processed this window
+
+ try
+ {
+ await CompactWindowAsync(key, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch
+ {
+ // Log error in production; for now skip failed windows
+ }
+ }
+ }
+
+ private async Task CompactWindowAsync(CompactionWindowKey key, CancellationToken ct)
+ {
+ using var activity = StlthLevelsMetrics.ActivitySource.StartActivity("levels.compaction");
+ activity?.SetTag("stream_id", key.StreamId.Value);
+ activity?.SetTag("venue", key.Venue);
+ var start = Stopwatch.GetTimestamp();
+
+ var rawFiles = RawFileLocator.FindSealedFilesInWindow(
+ _config.DataPath, key.Venue, key.StreamId.Value,
+ key.WindowStartNanos, _config.CompactionWindow);
+
+ if (rawFiles.Count == 0)
+ return;
+
+ var aggPath = AggFileWriter.ComputeAggPath(
+ _config.DataPath, key.Venue, key.StreamId.Value, key.WindowStartNanos);
+
+ // Skip if AGG already exists and is sealed
+ if (File.Exists(aggPath) && IsSealedAgg(aggPath))
+ return;
+
+ // Read header from first RAW file to get scales
+ int priceScale = 0, quantityScale = 0;
+ using (var fs = File.OpenRead(rawFiles[0]))
+ {
+ var reader = new BinaryRecordReader(fs);
+ priceScale = reader.Header.PriceScale;
+ quantityScale = reader.Header.QuantityScale;
+ }
+
+ var engine = new OrderbookReplayEngine(_config);
+ var replayRecords = engine.Replay(rawFiles);
+
+ var aggInfo = await AggFileWriter.WriteAsync(
+ replayRecords,
+ aggPath,
+ key.StreamId,
+ priceScale,
+ quantityScale,
+ rawFiles);
+
+ StlthLevelsMetrics.CompactionDuration.Record(Stopwatch.GetElapsedTime(start).TotalMilliseconds);
+ StlthLevelsMetrics.CompactionCompleted.Add(1);
+
+ if (_bus is { } bus)
+ await bus.PublishAggCreatedAsync(aggInfo);
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ _workChannel.Writer.TryComplete();
+
+ if (_backgroundTask is not null)
+ {
+ try { await _backgroundTask; }
+ catch (OperationCanceledException) { }
+ }
+
+ _cts?.Dispose();
+ _cts = null;
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await StopAsync(CancellationToken.None);
+ }
+
+ private string ExtractVenue(string filePath)
+ {
+ // Path format: {dataPath}/{venue}/{streamId}/file.raw
+ var relativePath = Path.GetRelativePath(_config.DataPath, filePath);
+ var parts = relativePath.Split(Path.DirectorySeparatorChar);
+ return parts.Length >= 3 ? parts[0] : "unknown";
+ }
+}
diff --git a/src/Levels.Compaction/Levels.Compaction.csproj b/src/Levels.Compaction/Levels.Compaction.csproj
new file mode 100644
index 0000000..5859284
--- /dev/null
+++ b/src/Levels.Compaction/Levels.Compaction.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Levels.Compaction/Log.cs b/src/Levels.Compaction/Log.cs
new file mode 100644
index 0000000..2bd17b8
--- /dev/null
+++ b/src/Levels.Compaction/Log.cs
@@ -0,0 +1,15 @@
+using Microsoft.Extensions.Logging;
+
+namespace Levels.Compaction;
+
+internal static partial class Log
+{
+ [LoggerMessage(Level = LogLevel.Information, Message = "Compaction started: Venue={Venue}, StreamId={StreamId}, WindowStart={WindowStart}")]
+ public static partial void CompactionStarted(ILogger logger, string venue, string streamId, string windowStart);
+
+ [LoggerMessage(Level = LogLevel.Information, Message = "Compaction completed: Venue={Venue}, StreamId={StreamId}, RecordCount={RecordCount}")]
+ public static partial void CompactionCompleted(ILogger logger, string venue, string streamId, long recordCount);
+
+ [LoggerMessage(Level = LogLevel.Warning, Message = "Compaction recovery: FilePath={FilePath}")]
+ public static partial void CompactionRecovery(ILogger logger, string filePath);
+}
diff --git a/src/Levels.Compaction/OrderbookReplayEngine.cs b/src/Levels.Compaction/OrderbookReplayEngine.cs
new file mode 100644
index 0000000..62d90e2
--- /dev/null
+++ b/src/Levels.Compaction/OrderbookReplayEngine.cs
@@ -0,0 +1,143 @@
+using Levels.Core.Format;
+using Levels.Core.IO;
+using Levels.Core.Orderbook;
+
+namespace Levels.Compaction;
+
+public sealed class OrderbookReplayEngine
+{
+ private readonly CompactionConfig _config;
+
+ public OrderbookReplayEngine(CompactionConfig config)
+ {
+ _config = config;
+ }
+
+ public IEnumerable Replay(
+ IReadOnlyList sortedRawFiles)
+ {
+ var bids = new OrderbookSide();
+ var asks = new OrderbookSide();
+ var deltaSinceSnap = 0;
+ var lastSnapTimeNanos = 0L;
+ var isFirstFile = true;
+
+ foreach (var filePath in sortedRawFiles)
+ {
+ using var fs = File.OpenRead(filePath);
+ var reader = new BinaryRecordReader(fs);
+ var isLeadingSnap = true;
+
+ foreach (var record in reader.ReadRecords(validateCrc: true))
+ {
+ var core = record.Core;
+
+ if (core.Type == RecordType.Snap)
+ {
+ // Leading SNAPs reset orderbook state at file boundaries
+ if (isLeadingSnap && !isFirstFile)
+ {
+ var side = core.Side == RecordSide.Bid ? bids : asks;
+ if (isLeadingSnap)
+ {
+ }
+ }
+
+ var obSide = core.Side == RecordSide.Bid ? bids : asks;
+ obSide.Apply(core.Price, core.Quantity);
+
+ yield return record;
+ }
+ else
+ {
+ isLeadingSnap = false;
+
+ if (core.Type == RecordType.Delta)
+ {
+ var obSide = core.Side == RecordSide.Bid ? bids : asks;
+ obSide.Apply(core.Price, core.Quantity);
+ deltaSinceSnap++;
+
+ if (lastSnapTimeNanos == 0)
+ lastSnapTimeNanos = core.ObservedTime;
+ }
+
+ yield return record;
+
+ // Check if synthetic SNAP should be emitted
+ if (ShouldEmitSyntheticSnap(deltaSinceSnap, core.ObservedTime, lastSnapTimeNanos))
+ {
+ foreach (var snap in EmitSyntheticSnaps(bids, asks, core.ObservedTime, core.PriceStreamId))
+ yield return snap;
+
+ deltaSinceSnap = 0;
+ lastSnapTimeNanos = core.ObservedTime;
+ }
+ }
+ }
+
+ // After first file's leading SNAPs, subsequent files reset state
+ if (isFirstFile)
+ {
+ isFirstFile = false;
+ if (lastSnapTimeNanos == 0)
+ lastSnapTimeNanos = bids.Levels.Any() || asks.Levels.Any()
+ ? 0 // will be set on first delta
+ : 0;
+ }
+ }
+ }
+
+ private bool ShouldEmitSyntheticSnap(int deltaSinceSnap, long currentTimeNanos, long lastSnapTimeNanos)
+ {
+ if (deltaSinceSnap >= _config.SyntheticSnapIntervalDeltas)
+ return true;
+
+ if (_config.SyntheticSnapIntervalTime is { } interval && lastSnapTimeNanos > 0)
+ {
+ var intervalNanos = interval.Ticks * 100;
+ if (currentTimeNanos - lastSnapTimeNanos >= intervalNanos)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static IEnumerable EmitSyntheticSnaps(
+ OrderbookSide bids, OrderbookSide asks, long observedTime, long priceStreamId)
+ {
+ uint seq = 0;
+
+ foreach (var level in bids.Levels)
+ {
+ var core = new CoreRecordLayout
+ {
+ ObservedTime = observedTime,
+ PriceStreamId = priceStreamId,
+ Price = level.Key,
+ Quantity = level.Value,
+ Type = RecordType.Snap,
+ Side = RecordSide.Bid,
+ Flags = Constants.SyntheticSnapFlag,
+ Sequence = seq++,
+ };
+ yield return new RawRecord { Core = core };
+ }
+
+ foreach (var level in asks.Levels)
+ {
+ var core = new CoreRecordLayout
+ {
+ ObservedTime = observedTime,
+ PriceStreamId = priceStreamId,
+ Price = level.Key,
+ Quantity = level.Value,
+ Type = RecordType.Snap,
+ Side = RecordSide.Ask,
+ Flags = Constants.SyntheticSnapFlag,
+ Sequence = seq++,
+ };
+ yield return new RawRecord { Core = core };
+ }
+ }
+}
diff --git a/src/Levels.Compaction/RawFileLocator.cs b/src/Levels.Compaction/RawFileLocator.cs
new file mode 100644
index 0000000..d8f3994
--- /dev/null
+++ b/src/Levels.Compaction/RawFileLocator.cs
@@ -0,0 +1,67 @@
+using Levels.Core.Format;
+using Levels.Core.IO;
+
+namespace Levels.Compaction;
+
+public static class RawFileLocator
+{
+ public static List FindSealedFiles(string dataPath, string venue, long priceStreamId)
+ {
+ var dir = Path.Combine(dataPath, venue, priceStreamId.ToString());
+ if (!Directory.Exists(dir))
+ return [];
+
+ var result = new List();
+ foreach (var file in Directory.GetFiles(dir, "*.raw").OrderBy(f => f))
+ {
+ if (IsSealedFile(file))
+ result.Add(file);
+ }
+
+ return result;
+ }
+
+ public static List FindSealedFilesInWindow(
+ string dataPath, string venue, long priceStreamId,
+ long windowStartNanos, TimeSpan windowSpan)
+ {
+ var windowEndNanos = windowStartNanos + windowSpan.Ticks * 100;
+ var allSealed = FindSealedFiles(dataPath, venue, priceStreamId);
+ var result = new List();
+
+ foreach (var file in allSealed)
+ {
+ using var fs = File.OpenRead(file);
+ var reader = new BinaryRecordReader(fs);
+
+ if (reader.Footer is null)
+ continue;
+
+ var firstObs = reader.Footer.Value.FirstObservedTime;
+ var lastObs = reader.Footer.Value.LastObservedTime;
+
+ // File overlaps window if its range intersects [windowStart, windowEnd)
+ if (firstObs < windowEndNanos && lastObs >= windowStartNanos)
+ result.Add(file);
+ }
+
+ return result;
+ }
+
+ private static bool IsSealedFile(string filePath)
+ {
+ try
+ {
+ using var fs = File.OpenRead(filePath);
+ if (fs.Length < Constants.HeaderSize + Constants.FooterSize)
+ return false;
+
+ var reader = new BinaryRecordReader(fs);
+ return reader.Footer is not null;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/Levels.Core/AggFileInfo.cs b/src/Levels.Core/AggFileInfo.cs
new file mode 100644
index 0000000..ba6e322
--- /dev/null
+++ b/src/Levels.Core/AggFileInfo.cs
@@ -0,0 +1,11 @@
+namespace Levels.Core;
+
+public readonly record struct AggFileInfo(
+ string FilePath,
+ PriceStreamId PriceStreamId,
+ long RecordCount,
+ long DeltaCount,
+ long SyntheticSnapCount,
+ long FirstObservedTime,
+ long LastObservedTime,
+ IReadOnlyList SourceRawFiles);
diff --git a/src/Levels.Core/Diagnostics/StlthLevelsMetrics.cs b/src/Levels.Core/Diagnostics/StlthLevelsMetrics.cs
new file mode 100644
index 0000000..5e08e71
--- /dev/null
+++ b/src/Levels.Core/Diagnostics/StlthLevelsMetrics.cs
@@ -0,0 +1,42 @@
+using System.Diagnostics;
+using System.Diagnostics.Metrics;
+
+namespace Levels.Core.Diagnostics;
+
+public static class StlthLevelsMetrics
+{
+ private static readonly Meter Meter = new("Levels", "1.0.0");
+ public static readonly ActivitySource ActivitySource = new("Levels", "1.0.0");
+
+ // Writer metrics
+ public static readonly Counter RecordsWritten = Meter.CreateCounter("pricestorage.records.written");
+ public static readonly Histogram RecordWriteLatency = Meter.CreateHistogram("pricestorage.record.write_latency", "ms");
+ public static readonly Counter FilesSealed = Meter.CreateCounter("pricestorage.files.sealed");
+
+ // Sink metrics
+ public static readonly UpDownCounter ActiveStreams = Meter.CreateUpDownCounter("pricestorage.streams.active");
+ public static readonly Counter CircuitBreakerTrips = Meter.CreateCounter("pricestorage.circuit_breaker.trips");
+
+ // Compaction metrics
+ public static readonly Counter CompactionCompleted = Meter.CreateCounter("pricestorage.compaction.completed");
+ public static readonly Histogram CompactionDuration = Meter.CreateHistogram("pricestorage.compaction.duration", "ms");
+
+ // Health check & promotion metrics
+ public static readonly Counter HealthCheckPassed = Meter.CreateCounter("pricestorage.healthcheck.passed");
+ public static readonly Counter HealthCheckFailed = Meter.CreateCounter("pricestorage.healthcheck.failed");
+ public static readonly Counter PromotionsCompleted = Meter.CreateCounter("pricestorage.promotions.completed");
+
+ // Query metrics
+ public static readonly Histogram QueryLatency = Meter.CreateHistogram("pricestorage.query.latency", "ms");
+ public static readonly Counter QueriesExecuted = Meter.CreateCounter("pricestorage.queries.executed");
+
+ // DataFlow metrics
+ public static readonly Counter DataFlowBackpressure = Meter.CreateCounter("pricestorage.dataflow.backpressure");
+
+ // TCP server metrics
+ public static readonly UpDownCounter ActiveConnections = Meter.CreateUpDownCounter("pricestorage.connections.active");
+ public static readonly Counter TcpMessages = Meter.CreateCounter("pricestorage.tcp.messages");
+
+ // Archival metrics
+ public static readonly Counter ArchivalFilesDeleted = Meter.CreateCounter("pricestorage.archival.files_deleted");
+}
diff --git a/src/Levels.Core/Format/Constants.cs b/src/Levels.Core/Format/Constants.cs
new file mode 100644
index 0000000..fcff1f6
--- /dev/null
+++ b/src/Levels.Core/Format/Constants.cs
@@ -0,0 +1,30 @@
+namespace Levels.Core.Format;
+
+///
+/// Levels binary format constants.
+/// The canonical byte order is little-endian. All MemoryMarshal operations
+/// assume LE layout. A runtime check in the static constructor ensures this.
+///
+public static class Constants
+{
+ static Constants()
+ {
+ if (!BitConverter.IsLittleEndian)
+ throw new PlatformNotSupportedException(
+ "Levels binary format requires a little-endian platform.");
+ }
+
+ public static ReadOnlySpan HeaderMagic => "LEVELS01"u8;
+ public static ReadOnlySpan FooterMagicEnd => "LEVEND01"u8;
+
+ public static readonly ulong HeaderMagicUInt64 = BitConverter.ToUInt64(HeaderMagic);
+ public static readonly ulong FooterMagicUInt64 = BitConverter.ToUInt64(FooterMagicEnd);
+
+ public const ushort FormatVersion = 2;
+ public const int HeaderSize = 128;
+ public const int FooterSize = 64;
+ public const int CoreRecordSize = 56;
+
+ public const ushort SyntheticSnapFlag = 0x0001;
+ public const ushort IsOwnerFlag = 0x0002;
+}
diff --git a/src/Levels.Core/Format/CoreRecordLayout.cs b/src/Levels.Core/Format/CoreRecordLayout.cs
new file mode 100644
index 0000000..de88092
--- /dev/null
+++ b/src/Levels.Core/Format/CoreRecordLayout.cs
@@ -0,0 +1,43 @@
+using System.Runtime.InteropServices;
+
+namespace Levels.Core.Format;
+
+[StructLayout(LayoutKind.Explicit, Size = Constants.CoreRecordSize)]
+public struct CoreRecordLayout
+{
+ [FieldOffset(0)]
+ public long ObservedTime;
+
+ [FieldOffset(8)]
+ public long WriteTimestamp;
+
+ [FieldOffset(16)]
+ public long PriceStreamId;
+
+ [FieldOffset(24)]
+ public long Price;
+
+ [FieldOffset(32)]
+ public long Quantity;
+
+ [FieldOffset(40)]
+ public ushort Reserved;
+
+ [FieldOffset(42)]
+ public RecordType Type;
+
+ [FieldOffset(43)]
+ public RecordSide Side;
+
+ [FieldOffset(44)]
+ public uint Sequence;
+
+ [FieldOffset(48)]
+ public ushort Level;
+
+ [FieldOffset(50)]
+ public ushort Flags;
+
+ [FieldOffset(52)]
+ public uint Crc32;
+}
diff --git a/src/Levels.Core/Format/Crc32.cs b/src/Levels.Core/Format/Crc32.cs
new file mode 100644
index 0000000..1c68d42
--- /dev/null
+++ b/src/Levels.Core/Format/Crc32.cs
@@ -0,0 +1,22 @@
+using System.IO.Hashing;
+
+namespace Levels.Core.Format;
+
+public static class Crc32Util
+{
+ public static uint Compute(ReadOnlySpan data)
+ {
+ return Crc32.HashToUInt32(data);
+ }
+
+ public static uint ComputeRecord(ReadOnlySpan coreWithoutCrc, ReadOnlySpan ext)
+ {
+ var crc = new Crc32();
+ crc.Append(coreWithoutCrc);
+ if (ext.Length > 0)
+ crc.Append(ext);
+ return crc.GetCurrentHashAsUInt32();
+ }
+
+ public static Crc32 CreateIncremental() => new();
+}
diff --git a/src/Levels.Core/Format/FileFooter.cs b/src/Levels.Core/Format/FileFooter.cs
new file mode 100644
index 0000000..f7bc029
--- /dev/null
+++ b/src/Levels.Core/Format/FileFooter.cs
@@ -0,0 +1,44 @@
+using System.Runtime.InteropServices;
+
+namespace Levels.Core.Format;
+
+[StructLayout(LayoutKind.Explicit, Size = Constants.FooterSize)]
+public struct FileFooter
+{
+ [FieldOffset(0)]
+ public long RecordCount;
+
+ [FieldOffset(8)]
+ public long DeltaCount;
+
+ [FieldOffset(16)]
+ public long FirstWriteTimestamp;
+
+ [FieldOffset(24)]
+ public long LastWriteTimestamp;
+
+ [FieldOffset(32)]
+ public long FirstObservedTime;
+
+ [FieldOffset(40)]
+ public long LastObservedTime;
+
+ [FieldOffset(48)]
+ public uint FileCrc32;
+
+ [FieldOffset(52)]
+ public uint FooterCrc32;
+
+ [FieldOffset(56)]
+ public ulong MagicEnd;
+
+ public static void WriteTo(Span destination, in FileFooter footer)
+ {
+ MemoryMarshal.Write(destination, in footer);
+ }
+
+ public static FileFooter ReadFrom(ReadOnlySpan source)
+ {
+ return MemoryMarshal.Read(source);
+ }
+}
diff --git a/src/Levels.Core/Format/FileHeader.cs b/src/Levels.Core/Format/FileHeader.cs
new file mode 100644
index 0000000..23a14e3
--- /dev/null
+++ b/src/Levels.Core/Format/FileHeader.cs
@@ -0,0 +1,57 @@
+using System.Runtime.InteropServices;
+
+namespace Levels.Core.Format;
+
+[StructLayout(LayoutKind.Explicit, Size = Constants.HeaderSize)]
+public struct FileHeader
+{
+ [FieldOffset(0)]
+ public ulong Magic;
+
+ [FieldOffset(8)]
+ public ushort Version;
+
+ [FieldOffset(10)]
+ public FileType FileType;
+
+ // 1 byte padding at offset 11
+
+ [FieldOffset(12)]
+ public uint SchemaId;
+
+ [FieldOffset(16)]
+ public long PriceStreamId;
+
+ [FieldOffset(24)]
+ public long CreatedAt;
+
+ [FieldOffset(32)]
+ public int PriceScale;
+
+ [FieldOffset(36)]
+ public int QuantityScale;
+
+ ///
+ /// XxHash32 of the resampled config. Only meaningful when FileType == Resampled; zero otherwise.
+ ///
+ [FieldOffset(40)]
+ public uint ResampledConfigHash;
+
+ ///
+ /// Total size in bytes of one record (core + schema-defined fields like OrderId).
+ ///
+ [FieldOffset(44)]
+ public ushort RecordSize;
+
+ // Bytes 46..127 are reserved (82 bytes)
+
+ public static void WriteTo(Span destination, in FileHeader header)
+ {
+ MemoryMarshal.Write(destination, in header);
+ }
+
+ public static FileHeader ReadFrom(ReadOnlySpan source)
+ {
+ return MemoryMarshal.Read(source);
+ }
+}
diff --git a/src/Levels.Core/Format/FileType.cs b/src/Levels.Core/Format/FileType.cs
new file mode 100644
index 0000000..52c580b
--- /dev/null
+++ b/src/Levels.Core/Format/FileType.cs
@@ -0,0 +1,9 @@
+namespace Levels.Core.Format;
+
+public enum FileType : byte
+{
+ Raw = 0,
+ Agg = 1,
+ Period = 2,
+ Resampled = 3,
+}
diff --git a/src/Levels.Core/Format/RawRecord.cs b/src/Levels.Core/Format/RawRecord.cs
new file mode 100644
index 0000000..394b4cc
--- /dev/null
+++ b/src/Levels.Core/Format/RawRecord.cs
@@ -0,0 +1,15 @@
+namespace Levels.Core.Format;
+
+public readonly struct RawRecord
+{
+ public CoreRecordLayout Core { get; init; }
+ public ReadOnlyMemory RecordBytes { get; init; }
+
+ ///
+ /// Returns the OrderId portion of the record (bytes beyond the 56-byte core).
+ ///
+ public ReadOnlyMemory OrderId =>
+ RecordBytes.Length > Constants.CoreRecordSize
+ ? RecordBytes[Constants.CoreRecordSize..]
+ : default;
+}
diff --git a/src/Levels.Core/Format/RecordSide.cs b/src/Levels.Core/Format/RecordSide.cs
new file mode 100644
index 0000000..7e6e7fa
--- /dev/null
+++ b/src/Levels.Core/Format/RecordSide.cs
@@ -0,0 +1,8 @@
+namespace Levels.Core.Format;
+
+public enum RecordSide : byte
+{
+ Bid = 0,
+ Ask = 1,
+ Unknown = 2,
+}
diff --git a/src/Levels.Core/Format/RecordType.cs b/src/Levels.Core/Format/RecordType.cs
new file mode 100644
index 0000000..8bd293a
--- /dev/null
+++ b/src/Levels.Core/Format/RecordType.cs
@@ -0,0 +1,8 @@
+namespace Levels.Core.Format;
+
+public enum RecordType : byte
+{
+ Snap = 0,
+ Delta = 1,
+ Tombstone = 2,
+}
diff --git a/src/Levels.Core/Format/SchemaId.cs b/src/Levels.Core/Format/SchemaId.cs
new file mode 100644
index 0000000..d5b7ea4
--- /dev/null
+++ b/src/Levels.Core/Format/SchemaId.cs
@@ -0,0 +1,18 @@
+namespace Levels.Core.Format;
+
+public static class SchemaId
+{
+ private const uint FnvOffsetBasis = 2166136261;
+ private const uint FnvPrime = 16777619;
+
+ public static uint Compute(ReadOnlySpan data)
+ {
+ uint hash = FnvOffsetBasis;
+ for (int i = 0; i < data.Length; i++)
+ {
+ hash ^= data[i];
+ hash *= FnvPrime;
+ }
+ return hash;
+ }
+}
diff --git a/src/Levels.Core/IDataSink.cs b/src/Levels.Core/IDataSink.cs
new file mode 100644
index 0000000..263435f
--- /dev/null
+++ b/src/Levels.Core/IDataSink.cs
@@ -0,0 +1,6 @@
+namespace Levels.Core;
+
+public interface IDataSink
+{
+ ValueTask WriteAsync(T record, CancellationToken ct = default);
+}
diff --git a/src/Levels.Core/IO/BinaryRecordReader.cs b/src/Levels.Core/IO/BinaryRecordReader.cs
new file mode 100644
index 0000000..13bb206
--- /dev/null
+++ b/src/Levels.Core/IO/BinaryRecordReader.cs
@@ -0,0 +1,209 @@
+using System.Runtime.InteropServices;
+using Levels.Core.Format;
+using System.IO.Hashing;
+
+namespace Levels.Core.IO;
+
+public sealed class BinaryRecordReader : IDisposable
+{
+ private readonly Stream _stream;
+ private readonly FileHeader _header;
+ private readonly FileFooter? _footer;
+ private readonly int _recordSize;
+ private bool _disposed;
+
+ public FileHeader Header => _header;
+ public FileFooter? Footer => _footer;
+ public bool IsPartial => _footer is null;
+
+ public BinaryRecordReader(Stream stream)
+ {
+ _stream = stream;
+
+ // Read and validate header
+ Span headerBytes = stackalloc byte[Constants.HeaderSize];
+ ReadExact(stream, headerBytes);
+ _header = FileHeader.ReadFrom(headerBytes);
+
+ if (_header.Magic != Constants.HeaderMagicUInt64)
+ throw new InvalidDataException("Invalid file header magic bytes.");
+
+ if (_header.Version != Constants.FormatVersion)
+ throw new InvalidDataException($"Unsupported format version: {_header.Version}.");
+
+ // Determine record size from header
+ _recordSize = _header.RecordSize > 0 ? _header.RecordSize : Constants.CoreRecordSize;
+
+ if (_recordSize < Constants.CoreRecordSize)
+ throw new InvalidDataException($"Record size {_recordSize} is smaller than core record size {Constants.CoreRecordSize}.");
+
+ // Try to read footer
+ _footer = TryReadFooter(stream);
+
+ // Structural size validation for complete files
+ if (_footer is not null && stream.CanSeek)
+ {
+ long dataSize = stream.Length - Constants.HeaderSize - Constants.FooterSize;
+ if (dataSize < 0)
+ throw new InvalidDataException("File too small to contain header and footer.");
+
+ if (dataSize % _recordSize != 0)
+ throw new InvalidDataException(
+ $"File data region ({dataSize} bytes) is not evenly divisible by record size ({_recordSize} bytes).");
+
+ long expectedRecords = dataSize / _recordSize;
+ if (expectedRecords < _footer.Value.RecordCount)
+ throw new InvalidDataException(
+ $"File data region ({dataSize} bytes) is too small for {_footer.Value.RecordCount} records.");
+ }
+ }
+
+ private static FileFooter? TryReadFooter(Stream stream)
+ {
+ if (!stream.CanSeek)
+ return null;
+
+ var length = stream.Length;
+ if (length < Constants.HeaderSize + Constants.FooterSize)
+ return null;
+
+ var savedPosition = stream.Position;
+ try
+ {
+ stream.Seek(-Constants.FooterSize, SeekOrigin.End);
+ Span footerBytes = stackalloc byte[Constants.FooterSize];
+ ReadExact(stream, footerBytes);
+ var footer = FileFooter.ReadFrom(footerBytes);
+
+ if (footer.MagicEnd != Constants.FooterMagicUInt64)
+ return null;
+
+ // Validate footer CRC (backward compat: old files have 0 at offset 52)
+ if (footer.FooterCrc32 != 0)
+ {
+ uint computed = Crc32Util.Compute(footerBytes[..52]);
+ if (computed != footer.FooterCrc32)
+ return null;
+ }
+
+ return footer;
+ }
+ finally
+ {
+ stream.Position = savedPosition;
+ }
+ }
+
+ public IEnumerable ReadRecords(bool validateCrc = true)
+ {
+ _stream.Position = Constants.HeaderSize;
+
+ long endPosition = IsPartial
+ ? _stream.Length
+ : _stream.Length - Constants.FooterSize;
+
+ byte[] recordBuffer = new byte[_recordSize];
+ long count = 0;
+
+ while (_stream.Position + _recordSize <= endPosition)
+ {
+ int bytesRead = _stream.Read(recordBuffer, 0, _recordSize);
+ if (bytesRead < _recordSize)
+ break; // truncated record, discard
+
+ var core = MemoryMarshal.Read(recordBuffer);
+
+ // Validate CRC: record[0..52] + record[56.._recordSize]
+ if (validateCrc)
+ {
+ uint storedCrc = core.Crc32;
+ var crcData2 = _recordSize > Constants.CoreRecordSize
+ ? recordBuffer.AsSpan(Constants.CoreRecordSize)
+ : ReadOnlySpan.Empty;
+ uint computedCrc = Crc32Util.ComputeRecord(
+ recordBuffer.AsSpan(0, 52),
+ crcData2);
+
+ if (storedCrc != computedCrc)
+ throw new InvalidDataException(
+ $"CRC mismatch at record {count}: stored=0x{storedCrc:X8}, computed=0x{computedCrc:X8}.");
+ }
+
+ count++;
+ yield return new RawRecord
+ {
+ Core = core,
+ RecordBytes = recordBuffer.ToArray(),
+ };
+ }
+
+ // Validate record count for complete files
+ if (!IsPartial && _footer!.Value.RecordCount != count)
+ throw new InvalidDataException(
+ $"Record count mismatch: footer says {_footer.Value.RecordCount}, but read {count}.");
+ }
+
+ ///
+ /// Reads CRC-valid records from a partial (unsealed) file, stopping at the first invalid record.
+ /// Returns the count of valid records and the byte position after the last valid record.
+ ///
+ public (List Records, long ValidDataEnd) ReadValidRecordsFromPartial()
+ {
+ if (!IsPartial)
+ return (ReadRecords(validateCrc: true).ToList(), _stream.Length - Constants.FooterSize);
+
+ _stream.Position = Constants.HeaderSize;
+ var records = new List();
+ byte[] recordBuffer = new byte[_recordSize];
+
+ while (_stream.Position + _recordSize <= _stream.Length)
+ {
+ long recordStart = _stream.Position;
+ int bytesRead = _stream.Read(recordBuffer, 0, _recordSize);
+ if (bytesRead < _recordSize)
+ break;
+
+ var core = MemoryMarshal.Read(recordBuffer);
+ uint storedCrc = core.Crc32;
+ var crcData2 = _recordSize > Constants.CoreRecordSize
+ ? recordBuffer.AsSpan(Constants.CoreRecordSize)
+ : ReadOnlySpan.Empty;
+ uint computedCrc = Crc32Util.ComputeRecord(
+ recordBuffer.AsSpan(0, 52),
+ crcData2);
+
+ if (storedCrc != computedCrc)
+ break; // Stop at first invalid record
+
+ records.Add(new RawRecord
+ {
+ Core = core,
+ RecordBytes = recordBuffer.ToArray(),
+ });
+ }
+
+ long validEnd = Constants.HeaderSize + (long)records.Count * _recordSize;
+ return (records, validEnd);
+ }
+
+ private static void ReadExact(Stream stream, Span buffer)
+ {
+ int totalRead = 0;
+ while (totalRead < buffer.Length)
+ {
+ int read = stream.Read(buffer[totalRead..]);
+ if (read == 0)
+ throw new EndOfStreamException();
+ totalRead += read;
+ }
+ }
+
+ public void Dispose()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ // Don't dispose the stream — caller owns it.
+ }
+ }
+}
diff --git a/src/Levels.Core/IO/BinaryRecordWriter.cs b/src/Levels.Core/IO/BinaryRecordWriter.cs
new file mode 100644
index 0000000..631be18
--- /dev/null
+++ b/src/Levels.Core/IO/BinaryRecordWriter.cs
@@ -0,0 +1,212 @@
+using System.Buffers;
+using System.IO.Hashing;
+using System.IO.Pipelines;
+using System.Runtime.InteropServices;
+using Levels.Core.Format;
+
+namespace Levels.Core.IO;
+
+public sealed class BinaryRecordWriter : IAsyncDisposable
+{
+ private readonly PipeWriter _pipeWriter;
+ private readonly Stream _stream;
+ private readonly Crc32 _fileCrc = Crc32Util.CreateIncremental();
+ private readonly int _recordSize;
+ private readonly int _flushThresholdMs;
+ private readonly int _flushBufferSize;
+ private int _unflushedCount;
+ private long _lastFlushTicks;
+
+ private long _recordCount;
+ private long _deltaCount;
+ private long _firstWriteTimestamp;
+ private long _lastWriteTimestamp;
+ private long _firstObservedTime;
+ private long _lastObservedTime;
+ private bool _sealed;
+
+ public long RecordCount => _recordCount;
+ public long DeltaCount => _deltaCount;
+ public long FirstObservedTime => _firstObservedTime;
+ public long LastObservedTime => _lastObservedTime;
+
+ private BinaryRecordWriter(PipeWriter pipeWriter, Stream stream, int recordSize, int flushThresholdMs, int flushBufferSize)
+ {
+ _pipeWriter = pipeWriter;
+ _stream = stream;
+ _recordSize = recordSize;
+ _flushThresholdMs = flushThresholdMs;
+ _flushBufferSize = flushBufferSize;
+ _lastFlushTicks = Environment.TickCount64;
+ }
+
+ public static async Task CreateAsync(
+ Stream stream,
+ FileType fileType,
+ PriceStreamId priceStreamId,
+ int priceScale = 0,
+ int quantityScale = 0,
+ uint resampledConfigHash = 0,
+ uint schemaId = 0,
+ int recordSize = Constants.CoreRecordSize,
+ int flushThresholdMs = 0,
+ int flushBufferSize = 0)
+ {
+ var pipeWriter = PipeWriter.Create(stream, new StreamPipeWriterOptions(leaveOpen: true));
+ var writer = new BinaryRecordWriter(pipeWriter, stream, recordSize, flushThresholdMs, flushBufferSize);
+
+ var header = new FileHeader
+ {
+ Magic = Constants.HeaderMagicUInt64,
+ Version = Constants.FormatVersion,
+ FileType = fileType,
+ SchemaId = schemaId,
+ PriceStreamId = priceStreamId.Value,
+ CreatedAt = WriteTimestamp.Now(),
+ PriceScale = priceScale,
+ QuantityScale = quantityScale,
+ ResampledConfigHash = resampledConfigHash,
+ RecordSize = (ushort)recordSize,
+ };
+
+ var span = pipeWriter.GetSpan(Constants.HeaderSize);
+ span[..Constants.HeaderSize].Clear();
+ FileHeader.WriteTo(span, in header);
+ pipeWriter.Advance(Constants.HeaderSize);
+ await pipeWriter.FlushAsync();
+
+ return writer;
+ }
+
+ public ValueTask WriteRecordAsync(CoreRecordLayout core, ReadOnlyMemory orderIdBytes = default)
+ {
+ if (_sealed)
+ throw new InvalidOperationException("Writer has been sealed.");
+
+ return WriteRecordCoreAsync(core, orderIdBytes);
+ }
+
+ private async ValueTask WriteRecordCoreAsync(CoreRecordLayout core, ReadOnlyMemory orderIdBytes)
+ {
+ core.WriteTimestamp = WriteTimestamp.Now();
+ core.Reserved = 0;
+
+ // Build full record buffer
+ byte[]? rented = null;
+ Span recordBuf = _recordSize <= 256
+ ? stackalloc byte[_recordSize]
+ : (rented = ArrayPool.Shared.Rent(_recordSize)).AsSpan(0, _recordSize);
+
+ recordBuf.Clear();
+ MemoryMarshal.Write(recordBuf, in core);
+
+ // Copy extension bytes into bytes [56.._recordSize]
+ if (orderIdBytes.Length > 0 && _recordSize > Constants.CoreRecordSize)
+ {
+ var orderIdSpan = orderIdBytes.Span;
+ var destSlice = recordBuf[Constants.CoreRecordSize..];
+ if (orderIdSpan.Length > destSlice.Length)
+ throw new ArgumentException(
+ $"Extension bytes ({orderIdSpan.Length}) exceed record extension space ({destSlice.Length}).");
+ orderIdSpan.CopyTo(destSlice);
+ }
+
+ // Compute CRC over record[0..52] + record[56.._recordSize]
+ var crcData1 = recordBuf[..52];
+ var crcData2 = _recordSize > Constants.CoreRecordSize
+ ? recordBuf[Constants.CoreRecordSize..]
+ : ReadOnlySpan.Empty;
+ core.Crc32 = Crc32Util.ComputeRecord(crcData1, crcData2);
+
+ // Re-write core with CRC set
+ MemoryMarshal.Write(recordBuf, in core);
+
+ // Write full record to pipe
+ var destination = _pipeWriter.GetSpan(_recordSize);
+ recordBuf.CopyTo(destination);
+ _pipeWriter.Advance(_recordSize);
+
+ // Update file-level CRC
+ _fileCrc.Append(recordBuf);
+
+ if (rented is not null)
+ ArrayPool.Shared.Return(rented);
+
+ // Update stats
+ _recordCount++;
+ if (core.Type == RecordType.Delta)
+ _deltaCount++;
+
+ if (_firstWriteTimestamp == 0)
+ _firstWriteTimestamp = core.WriteTimestamp;
+ _lastWriteTimestamp = core.WriteTimestamp;
+
+ if (_firstObservedTime == 0 || core.ObservedTime < _firstObservedTime)
+ _firstObservedTime = core.ObservedTime;
+ if (core.ObservedTime > _lastObservedTime)
+ _lastObservedTime = core.ObservedTime;
+
+ _unflushedCount++;
+ bool batchingEnabled = _flushThresholdMs > 0 || _flushBufferSize > 0;
+ bool shouldFlush = !batchingEnabled
+ || (_flushBufferSize > 0 && _unflushedCount >= _flushBufferSize)
+ || (_flushThresholdMs > 0 && (Environment.TickCount64 - _lastFlushTicks) >= _flushThresholdMs);
+
+ if (shouldFlush)
+ {
+ await _pipeWriter.FlushAsync();
+ _unflushedCount = 0;
+ _lastFlushTicks = Environment.TickCount64;
+ }
+
+ return core;
+ }
+
+ public async ValueTask SealAsync()
+ {
+ if (_sealed)
+ throw new InvalidOperationException("Writer has already been sealed.");
+ _sealed = true;
+
+ var footer = new FileFooter
+ {
+ RecordCount = _recordCount,
+ DeltaCount = _deltaCount,
+ FirstWriteTimestamp = _firstWriteTimestamp,
+ LastWriteTimestamp = _lastWriteTimestamp,
+ FirstObservedTime = _firstObservedTime,
+ LastObservedTime = _lastObservedTime,
+ FileCrc32 = _fileCrc.GetCurrentHashAsUInt32(),
+ MagicEnd = Constants.FooterMagicUInt64,
+ };
+
+ // Compute footer CRC over bytes [0..52) (all fields before FooterCrc32)
+ Span