DEV Community

RizeTrade
RizeTrade

Posted on

Designing the data model for a trading journal

Every trader I know starts journaling in a spreadsheet, and every one of them abandons it within a month.

Not because journaling is useless, but because the spreadsheet can't answer the questions that actually matter: "which of my rules costs me the most money?" or "am I cutting winners too early?"
A flat sheet of rows can't tell you.

Tools like RizeTrade solve this with a proper data model, and in this post I'll walk through how to design one yourself.

The problem is almost always the data model.
If you design the schema well up front, the hard reports fall out of it for free.

Here's how I'd structure it.

*Start with the trade, but don't overload it
*

The instinct is to make one giant trades table with fifty columns.
Resist it.

A trade has a small core of facts:

CREATE TABLE trades (
  id            BIGSERIAL PRIMARY KEY,
  symbol        TEXT NOT NULL,
  side          TEXT NOT NULL CHECK (side IN ('long', 'short')),
  entry_price   NUMERIC NOT NULL,
  exit_price    NUMERIC,
  quantity      NUMERIC NOT NULL,
  entry_at      TIMESTAMPTZ NOT NULL,
  exit_at       TIMESTAMPTZ,
  stop_loss     NUMERIC,
  take_profit   NUMERIC,
  commission    NUMERIC DEFAULT 0,
  strategy_id   BIGINT REFERENCES strategies(id)
);
Enter fullscreen mode Exit fullscreen mode

Everything else (emotions, notes, screenshots, rule checks) hangs off this in separate tables.

That separation is what lets you slice the data later without schema migrations every time you want a new report.

*R multiples belong in the model, not the UI
*

The single most useful number in trading isn't dollar P&L.

It's the R multiple: profit or loss expressed in units of the risk you planned to take.

A +2R win and a +2R win are comparable across a $500 account and a $50,000 account; two dollar figures aren't.

Planned risk is defined the moment you enter, by your stop:

planned_risk_per_unit = |entry_price - stop_loss|
planned_R             = (exit_price - entry_price) / planned_risk_per_unit   (for longs)
Enter fullscreen mode Exit fullscreen mode

Store the stop at entry time.

If you only record the realized exit, you lose the ability to detect stop loss violations, which is the whole point.

When realized risk exceeds planned risk, someone moved a stop.

That's a rule break you want the data to surface, not something you have to remember.

I compute R multiples as a view rather than a stored column, so a corrected entry price recalculates everything downstream:

CREATE VIEW trade_metrics AS
SELECT
  t.id,
  (t.exit_price - t.entry_price) * t.quantity - t.commission AS net_pnl,
  ABS(t.entry_price - t.stop_loss)                            AS planned_risk_unit,
  CASE WHEN t.side = 'long'
    THEN (t.exit_price - t.entry_price) / NULLIF(ABS(t.entry_price - t.stop_loss), 0)
    ELSE (t.entry_price - t.exit_price) / NULLIF(ABS(t.entry_price - t.stop_loss), 0)
  END AS realized_R
FROM trades t
WHERE t.exit_price IS NOT NULL;
Enter fullscreen mode Exit fullscreen mode

*Emotions and rules: many to many, always
*

Here's the modeling decision that unlocks the reports people actually want.

Emotions and rule checks are not columns on the trade.

A single trade can carry several emotions (FOMO and impatience), and a strategy has several rules, each independently followed or broken.

That's two many to many relationships:

CREATE TABLE emotions (id BIGSERIAL PRIMARY KEY, name TEXT UNIQUE);  -- fomo, tilt, revenge...

CREATE TABLE trade_emotions (
  trade_id   BIGINT REFERENCES trades(id),
  emotion_id BIGINT REFERENCES emotions(id),
  PRIMARY KEY (trade_id, emotion_id)
);

CREATE TABLE rule_checks (
  trade_id BIGINT REFERENCES trades(id),
  rule_id  BIGINT REFERENCES rules(id),
  followed BOOLEAN NOT NULL,
  PRIMARY KEY (trade_id, rule_id)
);
Enter fullscreen mode Exit fullscreen mode

Once this exists, "what does revenge trading cost me?" is a single join:

SELECT e.name, COUNT(*) AS trades, SUM(m.net_pnl) AS total_pnl
FROM trade_emotions te
JOIN emotions e      ON e.id = te.emotion_id
JOIN trade_metrics m ON m.id = te.trade_id
GROUP BY e.name
ORDER BY total_pnl ASC;
Enter fullscreen mode Exit fullscreen mode

And rule compliance per strategy, win rate when followed vs. broken, is a GROUP BY followed.

No pivot tables, no manual tagging math.

The model does it.

*Exit efficiency needs excursion data
*

To answer "am I cutting winners early?" you need more than entry and exit.

You need the best and worst prices the trade reached while open: MFE (maximum favorable excursion) and MAE (maximum adverse excursion).

Capture those from your price feed at close:

ALTER TABLE trades ADD COLUMN mfe_price NUMERIC;
ALTER TABLE trades ADD COLUMN mae_price NUMERIC;
Enter fullscreen mode Exit fullscreen mode

Exit efficiency is then actual profit captured as a fraction of the maximum that was available:

exit_efficiency = realized_move / (mfe_price - entry_price)
Enter fullscreen mode Exit fullscreen mode

Consistently low efficiency on winners is the quantitative fingerprint of exiting too early, a habit that's invisible in raw P&L but obvious once the excursion data is in the model.

*The payoff
*

None of these reports required special report writing logic.

They're all straightforward joins and aggregates, because the relationships (one trade to many emotions, one strategy to many rule checks, one trade to its excursions) were modeled explicitly instead of flattened into columns.

Get the schema right and the analytics are almost an afterthought.

Even if you build your own, start from the relationships, not the columns.

Top comments (0)