Skip to content
Closed
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
161 changes: 161 additions & 0 deletions di/eodtime/eodtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# di.eodtime

End-of-day time management for TorQ-based 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.

---

## Dependencies

| Dependency | Key | Required | Description |
|---|---|---|---|
| logger | `log` | yes | `info`, `warn`, `error` — each binary `{[c;m]}` where `c` is a symbol context and `m` is a string |

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

The `log` dependency must be passed to `init` inside the `configs` dict. The module
throws immediately if it is absent or missing any of the three required keys.

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
eodtime.init[`log`rolltimezone!(kxlog.createLog[];`$"Europe/London")]
```

---

## Initialisation

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

| Key | Required | Description |
|---|---|---|
| `log` | yes | Binary log dep — `info`, `warn`, `error` functions each `{[c;m]}` |
| `rolltimezone` | no | Timezone used for EOD roll scheduling. Default: `` `$"GMT" `` |
| `datatimezone` | no | Timezone used 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` |

Passing only the required `log` key loads the module with defaults that match TorQ's original `eodtime.q` behaviour:

```q
eodtime.init[enlist[`log]!enlist logdep]
```

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

Timezone values should be standard timezone identifiers in the format `"Region/City"` (e.g. `"Europe/London"`, `"America/New_York"`). The values `"GMT"`, `"UTC"` and `"Etc/GMT"` are handled as zero-offset shortcuts directly, without a timezone lookup. `"GMT"` is not recognised by `di.tz` but is the TorQ default, so this ensures the module works out of the box with existing TorQ products. Note: `"Etc/UTC"` is a valid argument in `di.tz`.

---

## Exported functions

### `init`
Initialises 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`
Returns the current trading date in the roll timezone.
```q
eodtime.getd[]
/ 2025.06.01
```

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

### `getdailyadj`
Returns the **cached** UTC offset for the data timezone - the value stored at the last `init` or `setdailyadj` call. Used by upstream processes to stamp incoming data:
```q
.z.p + eodtime.getdailyadj[]
/ adds the stored offset to the current UTC time
```

### `getdailyadjustment`
**Recomputes** the UTC offset for the data timezone at the current time. Call this after an EOD roll to get a fresh offset - important when the data timezone observes daylight saving time, as the offset changes at the transition.
```q
eodtime.getdailyadjustment[]
/ 0D01:00:00.000000000
```

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

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

### `setdailyadj`
Updates 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`
Updates 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

/ simulate what a tickerplant does at EOD roll
eodtime.setnextroll eodtime.getroll[.z.p];
eodtime.setdailyadj eodtime.getdailyadjustment[];

/ simulate what the segmented tickerplant does at EOD roll
eodtime.setd[1+eodtime.getd[]]
```

---

## TorQ migration pattern

| TorQ pattern | Module equivalent |
|--------------------------------------------------------|------------------------------------------------------|
| `.eodtime.d` (read) | `eodtime.getd[]` |
| `.eodtime.nextroll` (read) | `eodtime.getnextroll[]` |
| `.eodtime.dailyadj` (read) | `eodtime.getdailyadj[]` |
| `.eodtime.getroll[p]` | `eodtime.getroll[p]` |
| `.eodtime.getdailyadjustment[]` | `eodtime.getdailyadjustment[]` |
| `.eodtime.nextroll:.eodtime.getroll[.z.p]` | `eodtime.setnextroll eodtime.getroll[.z.p]` |
| `.eodtime.dailyadj:.eodtime.getdailyadjustment[]` | `eodtime.setdailyadj eodtime.getdailyadjustment[]` |
| `.eodtime.d+:1` | `eodtime.setd[1+eodtime.getd[]]` |

---

## 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 fresh. Always call `getdailyadjustment` after an EOD roll rather than relying on the cached value.
- `"GMT"`, `"UTC"` and `"Etc/GMT"` are not in the timezone database used by `di.tz` and are handled as zero-offset shortcuts directly in this module. `"Etc/UTC"` is valid and passed through to `di.tz` normally. All other timezone values are passed through to `di.tz`.
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these are in the export dict so should be moved to the "public api" section

/ ============================================================

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since these variables are dependent on each other would it be safer if there was just one setter function that updated them all together?

E.g. if we had

roll:{
  .z.m.d+:1;
  .z.m.nextroll:getroll[.z.p];
  .z.m.dailyadj:getdailyadjustment[];
};

then after EOD, a TP would just need to call .eodtime.roll[].

I know TorQ tickerplant.q sets them separately, but now that we have to do it via explicit setter functions, exposing three different setter functions might give the impression that they can be called independently.

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

init:{[configs]
/ initialise the eodtime module - validate deps, apply config, compute initial state
/ configs: dict containing `log (required) plus optional `rolltimezone, `datatimezone, `rolltimeoffset
/ log dep: `info`warn`error!({[c;m]};{[c;m]};{[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 configs;
'"di.eodtime: configs must be a dict with `log key"];
if[not `log in key configs;
'"di.eodtime: log dependency is required; pass `info`warn`error!(infofn;warnfn;errfn) keyed on `log"];
if[99h<>type configs`log;
'"di.eodtime: log value must be a dict; pass `info`warn`error functions"];
if[not all `info`warn`error in key configs`log;
'"di.eodtime: log dict must have `info`warn`error keys; got: ",(", " sv string key configs`log)];
.z.m.log:normlog configs`log;
.z.m.rolltimezone:$[`rolltimezone in key configs;configs`rolltimezone;`$"GMT"];
.z.m.datatimezone:$[`datatimezone in key configs;configs`datatimezone;`$"GMT"];
.z.m.rolltimeoffset:$[`rolltimeoffset in key configs;configs`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])
72 changes: 72 additions & 0 deletions di/eodtime/test.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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
fail,0,0,q,eodtime.init[enlist[`log]!enlist `info`warn!(logdep`info;logdep`warn)],1,1,errors when log dict is missing the error key
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