Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions di/eodtime/eodtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# di.eodtime

End-of-day time management for kdb+ tickerplant processes. Resolves the current trading date in a configurable roll timezone, calculates the next EOD roll timestamp in UTC, and computes a daily UTC offset used to timestamp incoming data.

---

## Features

- Compute the current trading date in a configurable roll timezone, handling DST transitions automatically
- Calculate the UTC timestamp of the next EOD roll from any given UTC timestamp, with support for non-midnight roll times via `rolltimeoffset`
- Compute a live UTC offset for a configurable data timezone, refreshable after each roll to capture DST changes
- Store and expose the current trading date, next roll timestamp, and daily adjustment offset as inspectable module state
- State setters allow tickerplant and segmented tickerplant processes to advance state after a roll without re-initialising
- `"GMT"`, `"UTC"`, and `"Etc/GMT"` are handled as zero-offset shortcuts without a timezone lookup - the module works out of the box with TorQ defaults

---

## Dependencies

| Dependency | Key | Required | Description |
|---|---|---|---|
| logger | `` `log `` | yes | `info` - binary `{[c;m]}` where `c` is a symbol context and `m` is a string. `warn` and `error` are accepted if present but never called by this module |

**Hard dependency:** `di.tz` - loaded automatically when the module is imported.

The `log` dependency must be passed to `init` inside the `configs` dict keyed on `` `log ``. The module throws immediately if `log` is absent or `info` is missing. `warn` and `error` are optional - a full `kx.log` instance or a complete `info`/`warn`/`error` dict both work without any changes from the caller.

Configuration keys `rolltimezone`, `datatimezone`, and `rolltimeoffset` are all optional - omit any or all of them and the module falls back to sensible defaults (GMT timezone, midnight roll). See Initialisation for full details.

A `kx.log` instance can be passed directly - the module normalises monadic functions to the binary `{[c;m]}` contract automatically:

```q
kxlog:use`kx.log
eodtime:use`di.eodtime

/ minimal - just the log dep, all config defaults
eodtime.init[enlist[`log]!enlist kxlog.createLog[]]

/ with timezone override
eodtime.init[`log`rolltimezone!(kxlog.createLog[];`$"Europe/London")]

/ fully specified
eodtime.init[`log`rolltimezone`datatimezone`rolltimeoffset!(kxlog.createLog[];`$"Europe/London";`$"Europe/London";0D17:00:00.000)]
```

---

## Initialisation

`init[deps]` takes a single dictionary combining the `log` dependency with any configuration overrides.

| Key | Required | Description |
|---|---|---|
| `` `log `` | yes | Log dep - at minimum `info` function `{[c;m]}` |
| `` `rolltimezone `` | no | Timezone for EOD roll scheduling. Default: `` `$"GMT" `` |
| `` `datatimezone `` | no | Timezone for stamping incoming data. Default: `` `$"GMT" `` |
| `` `rolltimeoffset `` | no | Offset from midnight for the EOD roll (e.g. `0D17:00:00.000` for a 5pm roll). Default: `0D` |

`init` must be called before any other function. It computes the initial values of `d`, `nextroll`, and `dailyadj`. Calling getters before `init` returns uninitialised defaults (`0Nd`, `0Wp`, `0D`) which will produce wrong results in downstream processes.

Timezone values should be standard identifiers in `"Region/City"` format (e.g. `"Europe/London"`, `"America/New_York"`). `"GMT"`, `"UTC"`, and `"Etc/GMT"` are zero-offset shortcuts handled directly without a `di.tz` lookup. Note: `"Etc/UTC"` is valid and passed through to `di.tz` normally.

---

## Exported Functions

### `init[deps]`
Initialise the module. Validates the log dependency, applies config, and computes initial values of `d`, `nextroll`, and `dailyadj`.
```q
eodtime.init[`log`rolltimezone`datatimezone`rolltimeoffset!(logdep;`$"Europe/London";`$"GMT";0D)]
/ or with defaults only:
eodtime.init[enlist[`log]!enlist logdep]
```

### `getd[]`
Return the current trading date in the roll timezone.
```q
eodtime.getd[]
/ 2025.06.01
```

### `getnextroll[]`
Return the UTC timestamp of the next scheduled EOD roll.
```q
eodtime.getnextroll[]
/ 2025.06.02D00:00:00.000000000
```

### `getdailyadj[]`
Return the **cached** UTC offset for the data timezone - the value stored at the last `init` or `setdailyadj` call.
```q
eodtime.getdailyadj[]
/ 0D01:00:00.000000000
```

### `getdailyadjustment[]`
**Recompute** the UTC offset for the data timezone at the current time. Call after an EOD roll to get a fresh offset - important when the data timezone observes DST.
```q
eodtime.getdailyadjustment[]
/ 0D01:00:00.000000000
```

### `getroll[p]`
Compute the UTC timestamp of the next EOD roll after UTC timestamp `p`. Returns today's roll time if the roll has not yet passed; tomorrow's if it has.
```q
eodtime.getroll[.z.p]
/ 2025.06.02D00:00:00.000000000
```

### `setnextroll[x]`
Update the stored next roll timestamp. Called by the tickerplant after completing an EOD roll.
```q
eodtime.setnextroll eodtime.getroll[.z.p]
```

### `setdailyadj[x]`
Update the stored daily adjustment offset. Called by the tickerplant after an EOD roll to refresh the offset for the new day.
```q
eodtime.setdailyadj eodtime.getdailyadjustment[]
```

### `setd[x]`
Update the stored trading date. Called by the segmented tickerplant to advance the date after an EOD roll.
```q
eodtime.setd[1+eodtime.getd[]]
```

---

## Usage Example

```q
kxlog:use`kx.log

/ load and initialise for a London-based system rolling at midnight
eodtime:use`di.eodtime
eodtime.init[`log`rolltimezone`datatimezone`rolltimeoffset!(kxlog.createLog[];`$"Europe/London";`$"GMT";0D)]

/ check current state
eodtime.getd[] / today's trading date in London time
eodtime.getnextroll[] / next midnight UTC (23:00 UTC in summer)
eodtime.getdailyadj[] / current UTC offset for data timestamping

/ at EOD roll - tickerplant advances state
eodtime.setnextroll eodtime.getroll[.z.p];
eodtime.setdailyadj eodtime.getdailyadjustment[];

/ at EOD roll - segmented tickerplant advances date
eodtime.setd[1+eodtime.getd[]]
```

---

## Running Tests

```q
k4unit:use`di.k4unit
k4unit.moduletest`di.eodtime
```

The test suite injects a no-op mock logger and a capturing logger that records `(level;msg)` pairs for assertion. It covers: dependency validation (non-dict deps throws; missing `log` key throws; non-dict log value throws; missing `info` key throws; `info`-only log dict succeeds); config defaults and overrides; `getroll` with GMT and non-GMT roll timezones including DST transitions (London winter vs summer); `getdailyadjustment` with UTC shortcuts and DST-aware timezones; state setters (`setnextroll`, `setdailyadj`, `setd`) and their corresponding getters; and a real `kx.log` integration test confirming the `normlog` wrapping works end-to-end.

---

## Notes

- `rolltimeoffset` adjusts the roll time from midnight (e.g. `0D17:00:00.000` produces a 5pm local roll)
- `getdailyadj` returns the cached offset; `getdailyadjustment` recomputes it live. Always call `getdailyadjustment` after an EOD roll rather than relying on the cached value
- `"GMT"`, `"UTC"`, and `"Etc/GMT"` are zero-offset shortcuts not passed to `di.tz`. `"Etc/UTC"` is valid and passes through normally
- Only `info` is required in the log dep - `warn` and `error` are accepted if present (e.g. from a full `kx.log` instance) but never called by this module
108 changes: 108 additions & 0 deletions di/eodtime/eodtime.q
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/ end-of-day time management - date resolution, roll scheduling and data timestamp adjustment

/ utc-equivalent timezone names - zero offset from utc so no timezone lookup required
utczones:`$("GMT";"UTC";"Etc/GMT");

/ ============================================================
/ module state and defaults
/ ============================================================

/ roll timezone for eod scheduling - overwritten by init
rolltimezone:`$"GMT";

/ data timezone for incoming data timestamping - overwritten by init
datatimezone:`$"GMT";

/ offset from midnight for the eod roll - overwritten by init
rolltimeoffset:0D00:00:00.000;

/ current trading date in rolltimezone - set by init
d:0Nd;

/ utc timestamp of next eod roll - set by init
nextroll:0Wp;

/ utc offset for datatimezone data stamping - set by init
dailyadj:0D00:00:00.000;

/ ============================================================
/ internal helpers
/ ============================================================

normlog:{[logdict]
/ detect kx.log instance by presence of kx.log-specific keys (getlvl, sinks, fmts)
/ kx.log functions are monadic - wrap each into binary {[c;m]} and embed context in the message
/ plain {[c;m]} log dicts (info`warn`error only) pass through unchanged
$[any `getlvl`sinks`fmts in key logdict;
`info`warn`error!(
{[fn;c;m] fn[string[c],": ",m]}[logdict`info;];
{[fn;c;m] fn[string[c],": ",m]}[logdict`warn;];
{[fn;c;m] fn[string[c],": ",m]}[logdict`error;]);
logdict]
};

/ returns timespan offset from utc for rolltimezone at timestamp p
adjtime:{[p]
/ utc-equivalent timezones have zero offset
if[rolltimezone in utczones;:0D];
`timespan$tz.gmttolocal[rolltimezone;p]-p
};

/ returns the date in rolltimezone for utc timestamp p
getday:{[p]"d"$(p+adjtime[p])-rolltimeoffset};

/ ============================================================
/ public api
/ ============================================================

/ recomputes utc offset for datatimezone at current time
/ call after an eod roll to get a fresh offset - important when datatimezone observes dst
getdailyadjustment:{
/ utc-equivalent timezones have zero offset
if[datatimezone in utczones;:0D];
`timespan$tz.gmttolocal[datatimezone;.z.p]-.z.p
};

/ returns utc timestamp of next eod roll after utc timestamp p
getroll:{[p]
/ mod[z,1D] normalises the roll time to [0D,1D) to handle timezone offsets crossing midnight
z:rolltimeoffset-adjtime[p];
z:`timespan$(mod) . "j"$z,1D;
/ kdb-x 5.0: comparing timespan to timestamp checks against p's time-of-day component - true means roll has already passed
("d"$p)+$[z<=p;z+1D;z]
};

/ state getters
getd:{d};
getnextroll:{nextroll};
getdailyadj:{dailyadj};

/ state setters
setnextroll:{.z.m.nextroll:x};
setdailyadj:{.z.m.dailyadj:x};
setd:{.z.m.d:x};

init:{[deps]
/ initialise the eodtime module - validate deps, apply config, compute initial state
/ deps: dict containing `log (required) plus optional `rolltimezone, `datatimezone, `rolltimeoffset
/ log dep: at minimum `info!{[c;m]} - binary, c=context symbol, m=string
/ examples:
/ eodtime.init[enlist[`log]!enlist logdep]
/ eodtime.init[`log`rolltimezone!(logdep;`$"Europe/London")]
if[99h<>type deps;
'"di.eodtime: deps must be a dict with `log key"];
if[not `log in key deps;
'"di.eodtime: log dependency is required; pass at minimum `info!{[c;m]} keyed on `log"];
if[99h<>type deps`log;
'"di.eodtime: log value must be a dict; pass at minimum `info!{[c;m]}"];
if[not `info in key deps`log;
'"di.eodtime: log dict must have at minimum an `info key; got: ",(", " sv string key deps`log)];
.z.m.log:normlog deps`log;
.z.m.rolltimezone:$[`rolltimezone in key deps;deps`rolltimezone;`$"GMT"];
.z.m.datatimezone:$[`datatimezone in key deps;deps`datatimezone;`$"GMT"];
.z.m.rolltimeoffset:$[`rolltimeoffset in key deps;deps`rolltimeoffset;0D00:00:00.000];
.z.m.dailyadj:getdailyadjustment[];
.z.m.d:getday[.z.p];
.z.m.nextroll:getroll[.z.p];
.z.m.log[`info][`eodtime;"initialised with rolltimezone=",string[rolltimezone]," datatimezone=",string[datatimezone]," rolltimeoffset=",string rolltimeoffset];
};
7 changes: 7 additions & 0 deletions di/eodtime/init.q
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/ end-of-day time management - date resolution, roll scheduling and data timestamp adjustment

tz:use`di.tz

\l ::eodtime.q

export:([init;getd;getnextroll;getdailyadj;getroll;getdailyadjustment;setnextroll;setdailyadj;setd])
90 changes: 90 additions & 0 deletions di/eodtime/test.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
action,ms,bytes,lang,code,repeat,minver,comment
comment,,,,,,,setup - load module and initialise with gmt defaults and log dep
before,0,0,q,eodtime:use`di.eodtime,1,1,load di.eodtime module
before,0,0,q,logdep:`info`warn`error!({[c;m]};{[c;m]};{[c;m]}),1,1,silent no-op log mock - binary {[c;m]} matches the log contract
before,0,0,q,eodtime.init[`rolltimezone`datatimezone`rolltimeoffset`log!(`$"GMT";`$"GMT";0D00:00:00.000;logdep)],1,1,init with gmt defaults and log dep

comment,,,,,,,getd
true,0,0,q,-14h=type eodtime.getd[],1,1,getd returns date type

comment,,,,,,,getnextroll
true,0,0,q,-12h=type eodtime.getnextroll[],1,1,getnextroll returns timestamp type
true,0,0,q,eodtime.getnextroll[]>.z.p,1,1,getnextroll returns future timestamp

comment,,,,,,,getdailyadj
true,0,0,q,-16h=type eodtime.getdailyadj[],1,1,getdailyadj returns timespan type
true,0,0,q,0D=eodtime.getdailyadj[],1,1,getdailyadj returns 0D for gmt datatimezone

comment,,,,,,,getdailyadjustment
true,0,0,q,-16h=type eodtime.getdailyadjustment[],1,1,getdailyadjustment returns timespan type
true,0,0,q,0D=eodtime.getdailyadjustment[],1,1,getdailyadjustment returns 0D for gmt datatimezone

comment,,,,,,,getroll - gmt zero offset
true,0,0,q,-12h=type eodtime.getroll[2025.01.01D12:00:00.000000000],1,1,getroll returns timestamp type
true,0,0,q,2025.01.02D00:00:00.000000000=eodtime.getroll[2025.01.01D12:00:00.000000000],1,1,getroll returns next midnight for gmt zero offset

comment,,,,,,,getroll - gmt with rolltimeoffset
run,0,0,q,eodtime.init[`rolltimezone`datatimezone`rolltimeoffset`log!(`$"GMT";`$"GMT";0D17:00:00.000;logdep)],1,1,reinit with 5pm gmt roll time
true,0,0,q,2025.01.01D17:00:00.000000000=eodtime.getroll[2025.01.01D12:00:00.000000000],1,1,getroll returns todays 5pm when before roll time
true,0,0,q,2025.01.02D17:00:00.000000000=eodtime.getroll[2025.01.01D18:00:00.000000000],1,1,getroll returns next day 5pm when past roll time

comment,,,,,,,setnextroll
run,0,0,q,eodtime.init[`rolltimezone`datatimezone`rolltimeoffset`log!(`$"GMT";`$"GMT";0D00:00:00.000;logdep)],1,1,reinit with gmt defaults
run,0,0,q,eodtime.setnextroll[2025.06.01D00:00:00.000000000],1,1,setnextroll does not error
true,0,0,q,2025.06.01D00:00:00.000000000=eodtime.getnextroll[],1,1,getnextroll reflects setnextroll

comment,,,,,,,setdailyadj
run,0,0,q,eodtime.setdailyadj[0D01:00:00.000000000],1,1,setdailyadj does not error
true,0,0,q,0D01:00:00.000000000=eodtime.getdailyadj[],1,1,getdailyadj reflects setdailyadj

comment,,,,,,,setd
run,0,0,q,eodtime.setd[2025.06.01],1,1,setd does not error
true,0,0,q,2025.06.01=eodtime.getd[],1,1,getd reflects setd

comment,,,,,,,utc-equivalent timezone shortcuts (GMT/UTC/Etc/GMT)
run,0,0,q,eodtime.init[`rolltimezone`datatimezone`rolltimeoffset`log!(`$"UTC";`$"Etc/GMT";0D00:00:00.000;logdep)],1,1,reinit with UTC/Etc/GMT shortcuts
true,0,0,q,0D=eodtime.getdailyadjustment[],1,1,Etc/GMT datatimezone returns 0D without lookup
true,0,0,q,2025.01.02D00:00:00.000000000=eodtime.getroll[2025.01.01D12:00:00.000000000],1,1,UTC rolltimezone returns correct roll

comment,,,,,,,non-gmt rolltimezone (Europe/London)
run,0,0,q,eodtime.init[`rolltimezone`datatimezone`rolltimeoffset`log!(`$"Europe/London";`$"GMT";0D00:00:00.000;logdep)],1,1,reinit with London rolltimezone
true,0,0,q,-12h=type eodtime.getroll[2025.01.01D12:00:00.000000000],1,1,getroll returns timestamp type for non-gmt rolltimezone
true,0,0,q,2025.01.02D00:00:00.000000000=eodtime.getroll[2025.01.01D12:00:00.000000000],1,1,getroll correct for london winter (utc+0)
true,0,0,q,2025.07.01D23:00:00.000000000=eodtime.getroll[2025.07.01D12:00:00.000000000],1,1,getroll correct for london summer (utc+1 - midnight local is 23:00 utc)

comment,,,,,,,non-gmt datatimezone (Europe/London - DST aware)
run,0,0,q,eodtime.init[`rolltimezone`datatimezone`rolltimeoffset`log!(`$"GMT";`$"Europe/London";0D00:00:00.000;logdep)],1,1,reinit with London datatimezone
true,0,0,q,-16h=type eodtime.getdailyadjustment[],1,1,Europe/London datatimezone returns timespan type
true,0,0,q,0D<=eodtime.getdailyadjustment[],1,1,Europe/London datatimezone returns non-negative offset

comment,,,,,,,init - empty config (just log dep) applies all defaults
run,0,0,q,eodtime.init[enlist[`log]!enlist logdep],1,1,init with just log dep applies all defaults
true,0,0,q,0D=eodtime.getdailyadj[],1,1,default datatimezone (GMT) produces zero daily adjustment
true,0,0,q,0D=eodtime.getdailyadjustment[],1,1,default datatimezone (GMT) produces zero computed adjustment
true,0,0,q,2025.01.02D00:00:00.000000000=eodtime.getroll[2025.01.01D12:00:00.000000000],1,1,default rolltimeoffset (0D) gives midnight roll

comment,,,,,,,init - non-dict configs throws
fail,0,0,q,eodtime.init[(::)],1,1,errors when configs is not a dictionary

comment,,,,,,,error cases
fail,0,0,q,eodtime.getroll[`notvalid],1,1,getroll throws on non-timestamp input

comment,,,,,,,init - dep validation
fail,0,0,q,eodtime.init[()!()],1,1,errors when log dependency is missing
fail,0,0,q,eodtime.init[enlist[`log]!enlist 42],1,1,errors when log value is not a dictionary
run,0,0,q,eodtime.init[enlist[`log]!enlist `info`warn!(logdep`info;logdep`warn)],1,1,init succeeds with info and warn but no error key - only info is required
run,0,0,q,.test.err:@[{eodtime.init[()!()]};(::);{x}],1,1,capture error string from init with missing log dep
true,0,0,q,.test.err like "di.eodtime:*",1,1,error is prefixed di.eodtime:

comment,,,,,,,init - kx.log instance normalised automatically (no manual wrapping required)
run,0,0,q,kxlogger:use`kx.log,1,1,load kx.log
run,0,0,q,kxinst:kxlogger.createLog[],1,1,create kx.log instance
run,0,0,q,eodtime.init[`log`rolltimezone!(kxinst;`$"GMT")],1,1,bare kx.log instance accepted without wrapping
run,0,0,q,.m.di.0eodtime.log[`info][`eodtime;"normalisation test"],1,1,normalised logger accepts binary {[c;m]} call without error

comment,,,,,,,log call verification - capturing logger sees init message with binary {[c;m]} args
run,0,0,q,caplog:`info`warn`error!({[c;m] `.test.cap upsert(`info;m)};{[c;m] `.test.cap upsert(`warn;m)};{[c;m] `.test.cap upsert(`error;m)}),1,1,capturing log mock - binary {[c;m]}
run,0,0,q,.test.cap:([] fn:`symbol$();msg:()),1,1,initialise log capture table
run,0,0,q,eodtime.init[enlist[`log]!enlist caplog],1,1,init with capturing logger
true,0,0,q,1=count select from .test.cap where fn=`info,1,1,one info entry captured on init
true,0,0,q,any (.test.cap`msg) like "initialised*",1,1,log message confirms initialisation