Before any engine logic, a trading system needs words it can trust. This episode walks through Kestrel's base types, the variant-index trick, tick rounding with an epsilon, hand-written IST time, and the three-file run config with its strict validator.
Intro
One Hundred Point Zero Five
Take the price one hundred point zero five, and a tick size of zero point zero five. Divide, and you expect exactly two thousand and one ticks. A computer may give you two thousand point nine nine nine nine, and floor that to two thousand. One tick, silently lost. This episode is about the small words that stop that kind of bug.
- 100.05 / 0.05
- Expected 2001
- Got 2000.999...
Where We Are
In the previous episode we covered the build system and the house rules of the codebase. Now we leave the build behind and look at the vocabulary every other module speaks: base types, instruments, ticks, Indian time, and the three files that configure a run.
- Base types
- Instruments and ticks
- IST time
- Run config
Learning Goals
By the end you will know the base types, see how a variant index doubles as an asset class, round prices to ticks safely, convert times to Indian Standard Time without local-time functions, and read the validator that guards the run config. Everything here is small, but everything later depends on it.
- Base types
- Variant index trick
- Ticks and IST
- Config validator
Base Types
The Base Types
In common slash types dot hpp, time is TimestampNs, a signed sixty-four-bit integer of nanoseconds since the Unix epoch. An InstrumentId is an unsigned thirty-two-bit integer where zero means none. OrderId is sixty-four bits with kNoOrder equal to zero. StrategyId is thirty-two bits, and kManualStrategy is zero.
- TimestampNs: int64 ns
- InstrumentId: 0 = none
- OrderId: kNoOrder = 0
- StrategyId: kManualStrategy = 0
Why Zero Means Nothing
Reserving zero as the none value means a default-initialised id is never mistaken for a real one. A forgotten field shows up as an obviously empty id instead of silently pointing at the first instrument. It is a tiny decision, but it removes a whole family of off-by-one confusions.
- Default id is empty
- No accidental match
- Real ids start at 1
Constants and Enums
Two constants matter. kSecondsPerYear is three hundred sixty-five point two five times eighty-six thousand four hundred, the calendar year used for option time. kQtyEpsilon is one in a billion, the threshold below which a quantity counts as zero. The enums are AssetClass, OrderType, TimeInForce and OrderStatus.
- kSecondsPerYear = 365.25 * 86400
- kQtyEpsilon = 1e-9
- Four core enums
The Enum Values
AssetClass has Equity, Future, Option and Crypto. OrderType is Market, Limit or Stop. TimeInForce is Gtc or Ioc. OrderStatus walks from PendingNew to New, then PartiallyFilled, Filled, Cancelled or Rejected. Keeping these closed and small makes every switch statement checkable by the compiler.
- AssetClass: four kinds
- OrderType: Market, Limit, Stop
- TimeInForce: Gtc, Ioc
- OrderStatus lifecycle
Bar and Event
A Bar holds an instrument, a start and end time, then open, high, low, close and volume. Events flowing through the engine are a std variant of BarEvent and CommandEvent, dispatched with std visit and a small Overloaded helper. The compiler then complains if a new event type is added and some handler forgets it.
- Bar: OHLCV plus start and end
- Event = variant of two
- std::visit with Overloaded
Instrument and Registry
What an Instrument Holds
An Instrument carries a symbol, a sourceId, or the word derived for synthetic ones, and a feedSymbol for the broker. It also has tickSize, lotSize, minQuantity, a multiplier, and a spec. The spec is a variant, and that is where the interesting trick lives.
- symbol, sourceId, feedSymbol
- tickSize, lotSize, minQuantity
- multiplier
- spec: a variant
The Variant Index Trick
Here is the code. InstrumentSpec is a variant of EquitySpec, FutureSpec, OptionSpec and CryptoSpec. The static assert says its size equals kAssetClassCount. And assetClass simply casts spec dot index to AssetClass. This is simplified from the source, shown for the idea.
- variant index is asset class
- static_assert keeps them in sync
Index Equals Class
Picture the variant alternatives numbered zero to three, and the enum values numbered the same way. Index zero is Equity, one is Future, two is Option, three is Crypto. There is no separate field to store, so there is nothing that can disagree with the data.
- 0 Equity
- 1 Future
- 2 Option
- 3 Crypto
What the Assert Prevents
Suppose someone adds a fifth alternative to the variant but forgets the enum. Without a guard, indices drift and an option could be treated as a future. The static assert turns that mistake into a compile error. The bug is caught before the program ever runs, which is the best time to catch it.
- Add a spec, forget the enum
- Compile error, not a runtime bug
Notional Applies the Multiplier
The notional function returns quantity times price times the instrument's multiplier. This is the only place in the codebase where the multiplier is applied. Everywhere else works in quantity and price, and asks notional when it needs money. One door in, so the multiplier can never be applied twice.
- qty * price * multiplier
- The only place it is applied
The Registry
The InstrumentRegistry hands out dense ids, one to N, in insertion order. That means lookup is just a vector index, with no hash map and no search. After every instrument is added you call resolveUnderlyings, which links each derivative to its underlying. Then derivativesOf an id lists everything built on it.
- Ids 1..N, insertion order
- Lookup = vector index
- resolveUnderlyings() after all adds
- derivativesOf(id)
Tick Rounding
The Problem With Division
Exchanges only accept prices on a grid. If the tick is zero point zero five, valid prices are multiples of it. But zero point zero five has no exact binary representation. So when you divide a price by the tick, the answer can land a hair below the integer you expected.
- Prices live on a tick grid
- 0.05 is not exact in binary
- Quotient can fall short
The Epsilon Fix
The fix is a small epsilon of one in ten million. roundToTickDown computes floor of price over tick plus the epsilon, times the tick. roundToTickUp computes ceil of price over tick minus the epsilon, times the tick. The nudge pushes near-integers onto the integer, without moving genuine fractions.
- Down: floor(p/t + 1e-7) * t
- Up: ceil(p/t - 1e-7) * t
Walking the Numbers
Take price one hundred point zero five, tick zero point zero five. The quotient is about two thousand point nine nine nine nine. Floor alone gives two thousand, which is one hundred point zero zero, a full tick lost. Add the epsilon and the floor sees two thousand and one, which gives one hundred point zero five, as intended.
- Plain floor: 2000, wrong
- With epsilon: 2001, right
- Result 100.05
Why Not a Bigger Epsilon
The epsilon must be tiny relative to a real fractional tick. One in ten million of a tick is far below any real price difference, so a price half a tick off the grid still rounds the way you would expect. The guard fixes representation noise and nothing else.
- Epsilon covers float noise
- Half a tick still rounds down
Quantities Toward Zero
Quantities get the same care. roundQtyDown rounds toward zero, using copysign so that a short position of minus seven point nine rounds to minus seven, not minus eight. Rounding toward zero never inflates the size of an order, long or short, which is the safe direction.
- Round toward zero
- copysign keeps the sign
- Never grows the order
IST Time
Time Is Stored in UTC
Internally every timestamp is nanoseconds since the Unix epoch, which is UTC. JSON uses milliseconds. Indian Standard Time is UTC plus five hours thirty minutes, fixed, with no daylight saving. So the offset is the constant kIstOffsetSeconds, nineteen thousand eight hundred, and conversion is plain addition.
- Stored in UTC
- IST = UTC + 05:30
- kIstOffsetSeconds = 19800
- No DST
Why Not Local Time
You might reach for timegm, localtime or a locale. Kestrel does not. Those depend on the machine's time zone settings, so a backtest could differ between a laptop and a server. The code parses and formats ISO eight six zero one by hand, so results depend only on the numbers, never on the environment.
- Local-time calls vary by machine
- Hand-written ISO-8601
- Same input, same output
Days From Civil
Dates are converted with Howard Hinnant's algorithms, daysFromCivil and civilFromDays. They map a year, month and day to a count of days since the epoch, and back, using only integer arithmetic. Because they are pure functions, they are easy to test against known dates.
- daysFromCivil
- civilFromDays
- Pure integer arithmetic
Floor Division for Negatives
Ordinary integer division truncates toward zero, which is wrong for times before the epoch. A moment one second before midnight is minus one, and should fall on the previous day, not day zero. So the code uses floorDiv and floorMod, which always round toward minus infinity.
- Truncation: wrong before epoch
- floorDiv rounds down
- floorMod stays non-negative
istDay and Minute of Day
Here is the second excerpt, simplified. istDay is floorDiv of the timestamp plus kIstOffsetNanos, by kNanosPerDay. istMinuteOfDay is floorMod of the same shifted value by kNanosPerDay, then divided by kNanosPerMinute. Shift first into IST, then split into day and minute.
- Shift by IST offset
- floorDiv gives the day
- floorMod gives minute of day
Weekday and hhmm
The weekday is floorMod of istDay plus three, by seven, with Monday equal to zero. The epoch day, first of January nineteen seventy, was a Thursday, which is why three is added. For human-friendly times, hhmmToMinute takes an hhmm integer: hours times sixty, plus the remainder after dividing by one hundred.
- istWeekday = floorMod(istDay + 3, 7)
- Monday = 0
- hhmm to minutes
The UTC Trading Day
One more subtlety. Risk day boundaries use the UTC date. That is safe because NSE trades from about three forty-five to ten hundred UTC, so a whole session sits inside one UTC calendar day, and it matches the IST trading day. The window never crosses midnight in either zone.
- NSE: 03:45 to 10:00 UTC
- Fits inside one UTC date
- UTC date = IST trading day
Run Config
Three Files, One Run
A run is described by three JSON files. strategy dot json has the schema kestrel dot strategy slash one. alpha dot json is kestrel dot alpha slash one. data dot json is kestrel dot data slash one. Separate files let you change data without touching strategy, and share alphas across runs.
- strategy.json
- alpha.json
- data.json
- Schema tag per file
One Legacy Shape
The three files are mapped into one legacy JSON, which is parsed into AppConfig. So every layout shares the same validation path, with no second parser to drift. If a mode override is given on the command line, it is applied before validation, so an overridden value is validated like any other.
- Three files mapped to one
- Single parser, single validator
- --mode applied before validation
Typos Are Errors
The validator treats unknown keys as errors, not warnings. A misspelled key never silently falls back to a default. It computes edit distance to suggest a did you mean. If you write snake case, it tells you to use the camelCase key. Errors read file, colon, JSON path, colon, message.
- Unknown key is an error
- Edit-distance did you mean
- snake_case: use camelCase
- file:jsonpath: message
No Secrets in Config
rejectSecrets refuses any key named token, accesstoken, secret, appsecret, password, pin, totp or apikey. The message tells you to name an environment variable with tokenEnv instead. Credentials live in the environment, never in files that get copied, committed or archived.
- Refuses token, secret, password...
- Use tokenEnv instead
- Names a variable, not a value
Paths and Fingerprints
Path strings may include tokens: dataRoot, configDir, strategy, mode, date, runId and pid. They are expanded when the config loads. The first log line prints the SHA-256 of each file, so any run can be matched to the exact bytes that configured it. dataRoot itself is the environment variable KESTREL underscore DATA underscore ROOT, or the data folder under the working directory.
- {dataRoot} {configDir} {strategy}
- {mode} {date} {runId} {pid}
- SHA-256 of each file in first log line
Worked Example
Square-Off at 1515
Now one worked example. The config says square-off at fifteen fifteen, written as the integer 1515. hhmmToMinute computes fifteen times sixty, which is nine hundred, plus fifteen. That is nine hundred and fifteen minutes after midnight, in IST.
- 1515 -> 15 * 60 + 15
- = 915 minutes
- IST minute of day
A Bar at 09:45 UTC
Now a timestamp at nine forty-five UTC. Add five hours thirty minutes and you get fifteen fifteen IST. Minute of day: nine hundred and fifteen. It equals the square-off minute, so the risk layer will flatten positions. The comparison is between two integers, with no strings and no time zones involved.
- 09:45 UTC + 05:30 = 15:15 IST
- Minute of day = 915
- 915 == 915: square-off
Pitfalls
Pitfall: Double Multiplier
First pitfall: applying the multiplier twice. If a caller multiplies quantity by price by the multiplier, then passes that to notional, which multiplies again, the exposure is inflated by the contract size. Because notional is the only place it happens, the rule is simple: never multiply yourself.
- Multiply once, in notional()
- Callers pass qty and price only
Pitfall: Local Time Calls
Second pitfall: local-time functions. timegm and locale-aware formatting behave differently across machines and settings. A backtest that depends on them is not reproducible, and a reproducible backtest is the whole point. Use the hand-written conversions and keep everything in UTC nanoseconds until the last moment.
- Avoid timegm, localtime, locales
- Keep UTC ns; convert at the edge
Pitfall: Secrets in Config
Third pitfall: putting secrets in config. Config files get copied between machines, archived with run results, and pasted into bug reports. The validator refuses secret-looking keys outright, so the safe habit is enforced rather than remembered. Name an environment variable, and keep the value out of the file.
- Files travel; environments stay
- Validator enforces it
Recap
Recap
Let us recap. Base types are fixed-width integers with zero meaning none. The variant index is the asset class, guarded by a static assert. Notional alone applies the multiplier. Tick rounding uses a tiny epsilon. Time is UTC, shifted to IST by a fixed offset with floor division. And the three-file run config is strictly validated.
- Zero means none
- Variant index = asset class
- One multiplier door
- Epsilon ticks, fixed IST
- Strict three-file config
Next: The History Store
This vocabulary is the ground floor. Next episode asks where historical bars actually live on disk, and how the store keeps its files from ever being corrupted. This series is educational and not financial advice.
- Next: the history store
- Subscribe for episode 4