---
title: "Ticks and research notes on one engine: a blueprint for a quant desk"
slug: build-quant-tick-store-and-research-memory
date: 2026-10-12
author: Semurg
section: how-to
tags: [build-on-semurg, finance, time-series, research, blueprint]
summary: A blueprint for a quant research desk: ticks into the time-series pack, the latest mark of every instrument in one call, and the research notes kept as a graph beside the instruments they are about.
status: published
covers: [route:POST /v1/query, op:ts_ingest, op:ts_latest, route:POST /v1/memory/remember, route:POST /v1/memory/context, sdk:query]
constants: [64, 1024]
---
A research desk lives on two kinds of data that usually sit in two systems. The first is numbers in bulk: ticks, quotes, fills. The
second is what the desk thinks about them: research notes, signals, the reasons a position was taken, linked to the instruments and
the people involved. This blueprint keeps both on one engine. The ticks go into the time-series pack, the notes into a knowledge graph
whose nodes are the same instruments, and a question about an instrument can be answered from both without an export between systems.

> Diagram quant-desk: Ticks go into the time-series pack under the series name ACME.XASX and come back as the latest mark; the same name is an instrument in the desk's research memory, which a strategy trades and a dated note mentions, so one name answers from both.

It is written for research and monitoring, and it is not the right tool for two jobs. It is not for the order path: we have not measured Semurg's latency for order routing. And it is not yet for large scans: a filtered sum over a large numeric column was our weakest published result, and it is being re-measured. What you get instead is the latest mark of a whole book in one call, and research notes that live beside the instruments they are about.

Everything below uses only what the published release does.

## The shape of the data

- **Ticks**: one series per instrument and venue, for example `ACME.XASX`. Each tick is a timestamp in milliseconds, a value and an optional size. Values are integers: choose one fixed-point scale per series (cents, or hundredths of a cent for instruments that need it) and keep it.
- **The latest mark**: every series keeps its newest tick where it can be read at once, so the latest mark of a whole book is one call.
- **Research memory**: instruments, issuers, strategies and people as entities in a scope for the desk; notes as passages that name the instruments they are about, each with the date it was written.

The numbers are stored as dense runs of fixed-width cells, which is how the engine keeps large numeric series compact. A note is stored
once and linked to every instrument it names.

## Step one: capture ticks

Your feed handler batches ticks and sends each batch in one call. A batch can hold up to a million rows.

```bash runnable kind=shell-node needs=token
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/query \
  -H "Authorization: Bearer $SEMURG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"op":"ts_ingest","rows":[["ACME.XASX",1760000000000,12345,500],["ACME.XASX",1760000001000,12350,200],
                              ["GLBX.XNYS",1760000000500,9870,1000],["GLBX.XNYS",1760000000400,9865,300]]}'
```

```expect
expect: "ok": true and "ticks": 4, with the number of series written
assert: json:.ok==true and .ticks==4
```

## Step two: the latest mark of a book

One call returns the newest tick of every series you name, in the order you named them. A series that was never written says so; it
is never answered with a zero.

```bash runnable kind=shell-node needs=token
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/query \
  -H "Authorization: Bearer $SEMURG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"op":"ts_latest","series":["ACME.XASX","GLBX.XNYS","NONE.XASX"]}'
```

```expect
expect: ACME.XASX at 12350 and GLBX.XNYS at 9870 (the newest tick by timestamp, not the last one sent), and NONE.XASX marked absent
assert: json:.ok==true and .latest[0].value==12350 and .latest[1].value==9870 and .latest[2].absent==true
```

## Step three: the desk's research memory

A note about an instrument is written into the desk's scope, naming the instrument, with the date it was written. The instrument's
name is the same series key, so the note and the ticks meet at the same name.

```bash runnable kind=shell-node needs=token
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/memory/remember \
  -H "Authorization: Bearer $SEMURG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"quant-desk","date":"2026-10-08","source":"morning research note",
       "facts":{"entities":[{"name":"ACME.XASX","type":"Instrument"},
                            {"name":"Acme","type":"Issuer"},
                            {"name":"Mean reversion","type":"Strategy"}],
                "relations":[{"source":"ACME.XASX","target":"Acme","type":"ISSUED_BY"},
                             {"source":"Mean reversion","target":"ACME.XASX","type":"TRADES"}]},
       "passages":[{"text":"ACME.XASX has overshot its range after the Acme guidance update; Mean reversion entries only below the prior close, sized small until the next update.",
                    "mentions":["ACME.XASX","Acme","Mean reversion"],"key":"note-acme-guidance"}]}'
```

```expect
expect: a JSON answer with "ok": true
assert: json:.ok==true
```

Before the open, ask the desk's memory what it holds about an instrument. Context starts from every name in the question, walks out
from them and returns the facts and the notes that mention them, each with its date:

```bash runnable kind=shell-node needs=token
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/memory/context \
  -H "Authorization: Bearer $SEMURG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"scope":"quant-desk","query":"What is our view on ACME.XASX?","hops":2}'
```

```expect
expect: "state": "answered", with the strategy that trades ACME.XASX and the morning note about the guidance update
assert: json:.state=="answered" and ([..|strings|ascii_downcase|select(contains("prior close"))]|length>0)
```

## The feed handler, in Python

With the Python client from the client archive at `https://one.semurg.io/dl/clients.tar.gz` (unpack it, then
`pip install -e clients/python`). The handler buffers ticks and sends them in batches, so the node takes one call per batch rather
than one per tick:

```python
import os
from semurg import Client

BATCH = 50000  # rows per call; one call may carry up to a million

class TickWriter:
    def __init__(self, client):
        self.client = client
        self.rows = []

    def on_tick(self, series, ts_ms, value, size):
        # value is an integer on the series' own fixed-point scale
        self.rows.append([series, ts_ms, value, size])
        if len(self.rows) >= BATCH:
            self.flush()

    def flush(self):
        if self.rows:
            reply = self.client.query("ts_ingest", {"rows": self.rows})
            assert reply.get("ok"), reply
            self.rows = []

with Client("http://localhost:4000", token=os.environ["SEMURG_API_TOKEN"]) as client:
    writer = TickWriter(client)
    writer.on_tick("ACME.XASX", 1760000002000, 12352, 100)
    writer.flush()
    print(client.query("ts_latest", {"series": ["ACME.XASX"]}))
```

## Scale and cost

A single node is free, with no licence key, and it is the full engine. A cluster is licensed per node; talk to us about the one you
need. Our benchmark results are being re-measured on new hardware and land on the [benchmark page](/benchmark) domain by domain,
each with the release they ran on. We have not
measured this blueprint's own workload.

## What it does not do today

- The time-series door takes ticks in and answers each series' latest tick. Reading a time range, an as-of join between series, and bars such as open, high, low and close are not in the published release's door. For backtests over history today, export to the tool you already use.
- Scans over large numeric columns are not yet our strength: a filtered sum over a large column was our weakest published result, and it is being re-measured.
- The research memory matches meaning only with the node's embedding model installed: until then a note about "the miner" does not reach an instrument named only by its ticker, so use the names you store.
- It is not an order management system and has no market-data entitlements of its own.

## When something is refused

- `422`: a row must be `["<series>", <ts_ms>, <value>]` or the same with a size, with integers within 64 bits and a series name of one to 1024 bytes; the refusal names the first bad row.
- `422`: a batch must hold between one row and a million; a latest-mark call between one series and a hundred thousand.
- `"absent": true` on a series means it was never written; `could_not_judge` means a record could not be read and says why. Neither is a zero.
- `501`: the node's release does not carry the time-series pack.
- `401`: the token is missing or wrong; `422` "missing scope" on a memory call without a scope.

## Next

- [Give your agent persistent memory with MCP in five minutes](/blog/agent-memory-mcp-five-minutes): the node and the seven memory tools.
- [Objects, streams, search and places](/blog/objects-streams-search-and-places): the other data operations on one route.
