The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryForward FillForward fill imputes a missing position with the most recent eligible earlier value.
- PrerequisiteMissing ValueA missing value indicates that the expected information is unavailable, unobserved, inapplicable, or withheld under a stated data contract.
- PrerequisiteObservation TimeObservation time is the timestamp used to place a measured value on a time axis.
- ImportantAvailability TimeAvailability time is the earliest evidenced instant at which information was usable by a specified system or decision process.
- ImportantCausal Knowledge CutoffThe time boundary that prevents information unavailable at that moment from influencing a calculation.
- ImportantEvent TimeEvent time is the timestamp assigned to when a market or business event occurred at its source.
- ImportantImputationImputation is the governed estimation or substitution of values where observations are missing.
- ImportantStale DataStale data is information whose age or lack of expected update makes it unsuitable under a declared freshness policy.
Practical promise. Build a common-time view from irregular observations without borrowing an event or correction that was not yet knowable, and make every expired carry visibly missing.
Two instruments can update milliseconds apart while a calculation still asks for values at exactly 14:30:01.000. Previous-tick carry-forward answers with the latest eligible observation. The word "eligible" does most of the work.
An event must be old enough to belong to the target grid, its stored revision must already be available at the claimed query time, and the selected source must still be within a declared expiry limit. If any of those tests fail, a plausible number is less useful than an explained missing value.
This method is often called previous-tick interpolation. That name is conventional, but potentially misleading: it does not estimate a value between two observed endpoints. It holds one recorded value forward as a step function.
Why one timestamp is not enough
Suppose a quote says its event time was 14:30:00.700 but the record does not become usable until 14:30:01.200. At a query cutoff of 14:30:01.000, its event belongs to the past but its information does not. Selecting it would be future-knowledge leakage.
A correction exposes the same issue. A revision can refer to an old event time and arrive seconds, minutes, or days later. Recomputing an old grid after that arrival can produce a valid revised vintage. It cannot make the correction knowable in the original vintage.
Keep four instants separate:
| Clock | Question it answers |
|---|---|
| Event time | When did the source say the observation occurred? |
| Availability time | When could this stored revision first be used? |
| Grid time | Which instant are we sampling? |
| Query time | What knowledge cutoff does this result claim? |
The package requires . A live snapshot normally uses . A later batch can use , but its output is a later vintage and must be versioned as such.
The two eligibility gates
Represent each source record as : event time, availability time, revision, and value. For instrument , grid , and query cutoff , form the candidate set
The first inequality prevents future-event backfill. The second prevents a late arrival or correction from leaking into a vintage that predates it.
From the candidates, choose the greatest event time. If multiple available revisions share that event time, choose the greatest revision:
This package validates that higher revision numbers have later availability times. A contradictory history is rejected rather than silently reordered.
The diagram has three missing outcomes because they mean different things. Before the first event there is no history. A known replay corpus may show that an event existed but was not yet available. A selected event can also exist and be knowable but be too old for the declared use.
Expiry is part of the algorithm
Let source age be and let be the maximum permitted age. This implementation emits
Equality passes. One millisecond beyond the limit expires. The stale output keeps the source event time, availability time, revision, and age, but its value is null so downstream code cannot accidentally treat it as usable.
max_staleness_ms is mandatory and finite in the reference implementation.
There is no universal correct threshold: it depends on the feed, instrument,
session, downstream decision, and clock quality. Requiring the caller to choose
and record a value prevents an invisible unlimited carry from becoming a
default.
Walk through an original and a corrected vintage
The canonical fixture is synthetic and uses ms.
- Instrument A has value 100.0 at event 14:30:00.100. Revision 0 becomes available at 14:30:00.180.
- At grid/query 14:30:01.000, the source is 900 ms old. It is carried as 100.0.
- Revision 1 changes that old event to 99.8 and becomes available at 14:30:01.400.
- The same grid evaluated with query cutoff 14:30:02.000 returns 99.8. That is a later corrected vintage, not a rewrite of what was known at 14:30:01.000.
- At grid/query 14:30:02.000, the source age is 1,900 ms. The status is
stale; the value is missing and the revision 1 lineage remains visible. - A 100.4 event at 14:30:02.200 is available at 02.260. At 14:30:03.000 it is 800 ms old and is carried.
Instrument B adds a delayed-arrival case. Its 14:30:00.700 event is not available until 14:30:01.200. The 14:30:01.000 live vintage cannot use it. A later query cutoff can.
What to notice in the static diagram: the correction moves only the knowledge cutoff, not the event. The expiry decision still measures grid time against event time. Availability decides whether a revision is knowable; staleness decides whether its source is still usable.
The output is a value plus an audit trail
Returning only the number hides the most important distinctions. Each sample contains:
grid_timeandquery_time;value, which is null for missing or stale states;source_event_timeandsource_available_time;source_revision;staleness_ms; and- a status:
exact,carried,stale,no_history, ornot_yet_available.
not_yet_available is a replay diagnostic. A live consumer sees only that no
eligible available record exists; it cannot know from silence alone that a
late event will arrive later.
Implementation outline
The Python and TypeScript versions use the same explicit procedure:
- validate a mandatory nonnegative integer expiry;
- validate all four clocks and request identity;
- reject nonfinite values, impossible availability order, and inconsistent revision histories;
- partition records by instrument;
- apply event and availability cutoffs for each request;
- select latest event and latest available revision; and
- apply expiry without discarding rejected-source lineage.
The reference code prioritizes readability over asymptotic optimization. A streaming system can maintain indexes or monotone cursors, but changing the data structure must not change the two eligibility gates or the boundary rule.
Explore the stale-versus-missing boundary
Open the interactive previous-tick lab. It starts with a complete synthetic preview, so the lesson is visible before the first click. Choose one of three 10-record scenarios:
- Steady stream: carries remain mostly fresh.
- Long gap: a legitimate source ages through the expiry boundary and turns missing.
- Late correction: an old event first arrives late and is then revised; neither version appears before its availability time.
Use Back and Step to move the query cutoff. Change the expiry to compare an old but still usable carry with an expired missing value. Play and Pause use the same deterministic transition as Step, and Reset restores the same informative starting state.
Tests that catch plausible-looking errors
The shared fixture and edge tests verify:
- a pre-first grid never backfills from a future event;
- an old event with a later availability time remains unavailable;
- a correction changes only later query vintages;
- age equal to the limit passes and the next millisecond expires;
- stale output is null but retains source lineage;
- exact event-time equality is classified correctly;
- impossible clocks, nonfinite values, duplicate revisions, and inconsistent revision order are rejected; and
- input observation order does not affect output.
Passing those checks proves implementation agreement with this contract. It does not prove that a chosen expiry is economically optimal or that any output predicts a return.
Gaps are observations, not explanations
A silent interval might reflect an illiquid instrument, a stable quote, a halt, a closed session, packet loss, or a delayed source. Carry-forward cannot identify the cause. It can only report its last eligible source and apply the declared expiry.
Do not automatically carry across a session boundary. The current official NYSE trading-hours calendar illustrates why holidays and early closes are venue rules rather than weekday assumptions. A production caller must partition or reset with the applicable venue and calendar version.
Corrections require versioned outputs
When revision 1 arrives, revision 0 remains the correct source for queries that predate revision 1's availability. Two operational patterns preserve this:
- store results by both grid and query vintage; or
- issue an explicit restatement that links the old output, new output, source revision, and algorithm version.
Overwriting history without a vintage erases the distinction between "happened then" and "became known later."
What the method can distort
Previous-tick panels can make asynchronous series look simultaneous. That convenience does not remove nonsynchronous-sampling effects. Hayashi and Yoshida's original covariance work addresses temporal overlap directly; it is not evidence that a carried rectangular panel is an unbiased covariance input.
The carry is also not a forecast, fair-value estimate, or proof of executability. A value can be legally selected under the data contract and still be operationally inappropriate for a trade, valuation, risk limit, or regulatory report.
Why the worked case is synthetic
A historical sparse quote sequence would be useful only if readers could trace its event and availability clocks and the source permitted redistribution. It would also need a documented venue, feed, session, quote side, and expiry rule.
This article deliberately does not present a licensed provider extract as public fact. Its examples are deterministic synthetic records that teach the mechanics without implying a historical market claim. A real-world case remains a medium-priority enhancement for a later evidence round.
Production checklist
- Pin venue, source, quote side, currency, scale, precision, calendar, and corporate-action policy.
- Store event time and first-usable availability time separately.
- Version corrections and downstream restatements.
- Require and monitor a finite expiry limit.
- Keep stale-source lineage even though the output value is missing.
- Reset or partition at authoritative session boundaries.
- Monitor rates of
no_history,not_yet_available, andstaleby source. - Re-run cross-language parity after every contract change.
- Treat synthetic examples as mechanics validation, not market evidence.
Summary and next topic
Previous-tick carry-forward is causal only under two cutoffs: event time must not exceed the grid, and availability time must not exceed the query cutoff. The selected source then has to pass an explicit expiry clock. Pre-history, late knowledge, and stale history stay missing for different, visible reasons.
Continue with Linear Quote Interpolation, where the future right endpoint makes retrospective knowledge even more explicit.
Primary references and visual asset map
Source roles and limitations are recorded in REFERENCES.md. The timestamp representation follows RFC 3339; nonsynchronous-sampling context comes from the original Hayashi-Yoshida paper; and venue sessions must use an authoritative calendar. The four-clock contract, status names, revision rules, and mandatory expiry are this package's declared implementation choices.
| Asset | Teaching role | Source | Static fallback |
|---|---|---|---|
| Two-clock overview | Event and knowledge eligibility | ../visuals/static/article-hero.svg | It is the fallback |
| Worked snapshots | Original, corrected, and expired vintage | ../visuals/static/causality-and-staleness.svg | It is the fallback |
| Interactive lab | Step through gaps, late arrivals, and expiry | ../visuals/animated/previous-tick-lab.html | Worked-snapshot SVG |
Rendered from the canonical Mermaid sources linked by this article.
Point-in-time previous-tick decision
This flow separates event eligibility, knowledge eligibility, and expiry.
Takeaway: a source can have an old event time and still be future knowledge; passing both cutoffs does not make it fresh enough to use.
References3 primary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
Accessed 2026-07-22. The package separates sourced facts from implementation choices. No licensed market-data payload or credential is included.
R01 - RFC 3339: Date and Time on the Internet: Timestamps
- Organization or authors: Internet Engineering Task Force; G. Klyne and C. Newman
- Source type: Official standards-track specification
- Publication or effective date: July 2002
- Version: RFC 3339; DOI 10.17487/RFC3339
- URL: https://www.rfc-editor.org/info/rfc3339/
- Accessed: 2026-07-22
- Jurisdiction: Global Internet protocol standard
- Supports: Unambiguous timestamp instants, UTC offsets, the
Zdesignator, and interoperable date-time representation - Limitations: Does not define exchange event time, system availability time, revision semantics, or a staleness threshold
R02 - On covariance estimation of non-synchronously observed diffusion processes
- Organization or authors: Takaki Hayashi and Nakahiro Yoshida
- Source type: Original peer-reviewed research paper, Bernoulli 11(2), 359-379
- Publication or effective date: 2005
- Version: DOI 10.3150/bj/1116340299
- URL: https://doi.org/10.3150/bj/1116340299
- Accessed: 2026-07-22
- Jurisdiction: Statistical methodology, not venue rules
- Supports: Nonsynchronous observation is material to covariance estimation; overlap-based methods address a different problem from rectangular previous-tick panels
- Limitations: Does not prescribe this package's data-engineering statuses, availability-time cutoff, correction policy, or expiry limit
R03 - NYSE Holidays and Trading Hours
- Organization or authors: New York Stock Exchange
- Source type: Official exchange calendar
- Publication or effective date: Current page includes 2026-2028 schedules at review
- Version: Live calendar page, reviewed 2026-07-22
- URL: https://www.nyse.com/trade/hours-calendars
- Accessed: 2026-07-22
- Jurisdiction: Named NYSE markets in the United States
- Supports: Sessions, holidays, and early closes are explicit venue rules; a carry policy must not infer them from weekday timestamps
- Limitations: Not applicable to every venue and subject to future updates
Implementation choices in this package
The following are explicit engineering policies, not facts attributed to the sources above:
query_time >= grid_time;- mandatory finite nonnegative
max_staleness_ms; - inclusive expiry equality;
- null output after expiry while retaining source lineage;
no_historyand retrospectivenot_yet_availablestatus labels;- latest available revision within the latest eligible event time; and
- rejection of revisions whose availability order contradicts revision order.
Dataset and historical-evidence classification
datasets/canonical.json: synthetic, CC0-1.0, exact shared parity fixturedatasets/teaching_scenarios.json: synthetic, CC0-1.0, playground-only- Historical example: deferred. A later addition must provide a redistributable primary source with event and availability timing plus a recorded expiry policy. Vendor-licensed observations must not be re-labeled as public facts.
Full dependency-light reference implementations in both supported languages.
/** Point-in-time-safe previous-tick carry-forward with explicit expiry. */
export type Observation = {
instrument: string;
event_time: string;
available_time: string;
revision: number;
value: number;
};
export type GridRequest = {grid_time: string; query_time: string};
export type SampleStatus = "no_history" | "not_yet_available" | "stale" | "exact" | "carried";
export type Sample = {
instrument: string;
grid_time: string;
query_time: string;
value: number | null;
source_event_time: string | null;
source_available_time: string | null;
source_revision: number | null;
staleness_ms: number | null;
status: SampleStatus;
};
const milliseconds = (timestamp: string): number => {
if (typeof timestamp !== "string" || !timestamp.endsWith("Z")) {
throw new Error("timestamps must be RFC 3339 UTC strings ending in Z");
}
const result = Date.parse(timestamp);
if (!Number.isFinite(result)) throw new Error(`invalid timestamp: ${timestamp}`);
return result;
};
const emptySample = (
instrument: string,
gridTime: string,
queryTime: string,
status: "no_history" | "not_yet_available",
): Sample => ({
instrument,
grid_time: gridTime,
query_time: queryTime,
value: null,
source_event_time: null,
source_available_time: null,
source_revision: null,
staleness_ms: null,
status,
});
export function previousTick(
observations: Observation[],
requests: GridRequest[],
maxStalenessMs: number,
): Sample[] {
if (!Number.isInteger(maxStalenessMs) || maxStalenessMs < 0) {
throw new Error("maxStalenessMs must be a non-negative integer");
}
const parsedRequests = requests.map((request) => {
const gridMs = milliseconds(request.grid_time);
const queryMs = milliseconds(request.query_time);
if (queryMs < gridMs) throw new Error("query_time must not precede grid_time");
return {...request, gridMs, queryMs};
});
const requestKeys = new Set(parsedRequests.map((row) => `${row.gridMs}\0${row.queryMs}`));
if (requestKeys.size !== parsedRequests.length) {
throw new Error("request (grid_time, query_time) pairs must be unique");
}
type ParsedObservation = Observation & {eventMs: number; availableMs: number};
const byInstrument = new Map<string, ParsedObservation[]>();
const revisionGroups = new Map<string, Array<{revision: number; availableMs: number}>>();
const revisionKeys = new Set<string>();
for (const row of observations) {
if (typeof row.instrument !== "string" || row.instrument.trim() === "") {
throw new Error("instrument must be a non-empty string");
}
const eventMs = milliseconds(row.event_time);
const availableMs = milliseconds(row.available_time);
if (availableMs < eventMs) throw new Error("available_time must not precede event_time");
if (!Number.isInteger(row.revision) || row.revision < 0) {
throw new Error("revision must be a non-negative integer");
}
if (!Number.isFinite(row.value)) throw new Error("value must be a finite number");
const key = `${row.instrument}\0${eventMs}\0${row.revision}`;
if (revisionKeys.has(key)) {
throw new Error("revisions must be unique per instrument and event_time");
}
revisionKeys.add(key);
const parsed = {...row, eventMs, availableMs};
byInstrument.set(row.instrument, [...(byInstrument.get(row.instrument) ?? []), parsed]);
const groupKey = `${row.instrument}\0${eventMs}`;
revisionGroups.set(groupKey, [
...(revisionGroups.get(groupKey) ?? []),
{revision: row.revision, availableMs},
]);
}
for (const group of revisionGroups.values()) {
group.sort((left, right) => left.revision - right.revision);
for (let index = 1; index < group.length; index += 1) {
if (group[index - 1].availableMs >= group[index].availableMs) {
throw new Error("higher revisions must have later available_time values");
}
}
}
const output: Sample[] = [];
for (const instrument of [...byInstrument.keys()].sort()) {
const rows = byInstrument.get(instrument)!;
for (const request of parsedRequests) {
const eventEligible = rows.filter((row) => row.eventMs <= request.gridMs);
const available = eventEligible.filter((row) => row.availableMs <= request.queryMs);
if (eventEligible.length === 0) {
output.push(emptySample(instrument, request.grid_time, request.query_time, "no_history"));
continue;
}
if (available.length === 0) {
output.push(emptySample(instrument, request.grid_time, request.query_time, "not_yet_available"));
continue;
}
const chosen = available.reduce((best, row) =>
row.eventMs > best.eventMs || (row.eventMs === best.eventMs && row.revision > best.revision)
? row
: best,
);
const stalenessMs = request.gridMs - chosen.eventMs;
const stale = stalenessMs > maxStalenessMs;
output.push({
instrument,
grid_time: request.grid_time,
query_time: request.query_time,
value: stale ? null : chosen.value,
source_event_time: chosen.event_time,
source_available_time: chosen.available_time,
source_revision: chosen.revision,
staleness_ms: stalenessMs,
status: stale ? "stale" : stalenessMs === 0 ? "exact" : "carried",
});
}
}
return output;
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.