diff --git a/di/eodtime/eodtime.md b/di/eodtime/eodtime.md new file mode 100644 index 00000000..54122c32 --- /dev/null +++ b/di/eodtime/eodtime.md @@ -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 diff --git a/di/eodtime/eodtime.q b/di/eodtime/eodtime.q new file mode 100644 index 00000000..975a8a43 --- /dev/null +++ b/di/eodtime/eodtime.q @@ -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]; + }; \ No newline at end of file diff --git a/di/eodtime/init.q b/di/eodtime/init.q new file mode 100644 index 00000000..027c0e12 --- /dev/null +++ b/di/eodtime/init.q @@ -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]) diff --git a/di/eodtime/test.csv b/di/eodtime/test.csv new file mode 100644 index 00000000..04262d96 --- /dev/null +++ b/di/eodtime/test.csv @@ -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