PostgreSQL 16 with the TimescaleDB extension. This document explains what the schema stores, why it is shaped the way it is, and how to run it.
The data currently in this database is invented. No broker is connected and no market feed is subscribed. Every row written by the seed carries
source = 'development_seed', and every API response carries asourcefield. Nothing in Phase 3 makes a number real — persistence only makes it durable, and a durable fiction is still a fiction.
- Running it
- Entity relationships
- The two halves of the schema
- Tables
- Design decisions
- Migrations
- Seeding
- Health and failure behaviour
- Testing
- Operational notes
docker compose up -d # TimescaleDB on localhost:5432
cd backend
.venv/Scripts/python -m alembic upgrade head
.venv/Scripts/python -m app.db.seed # optional, development onlyConnection settings come from the environment, never from a committed file:
| Variable | Default | Notes |
|---|---|---|
POSTGRES_HOST |
localhost |
|
POSTGRES_PORT |
5432 |
|
POSTGRES_DB |
trading_agent |
|
POSTGRES_USER |
trading_agent |
|
POSTGRES_PASSWORD |
dev default | SecretStr; masked wherever it is printed |
REPOSITORY_BACKEND |
postgres |
mock runs the API with no database |
DB_POOL_SIZE |
5 |
plus DB_MAX_OVERFLOW=5 |
DB_HEALTH_TIMEOUT |
5.0 |
ceiling on the health probe's SELECT 1 |
The URL is assembled from parts rather than read as a single DSN. A URL in an environment variable is the classic way a password ends up in a shell history, a log line or a crash report.
REPOSITORY_BACKEND=mock is a configuration switch, not a fallback.
Nothing catches a connection error and quietly serves mock figures instead —
see Health and failure behaviour.
erDiagram
MONTHLY_TARGETS ||--o{ MONTHLY_TARGET_HISTORY : "audited by"
MARKET_INSTRUMENTS ||--o{ OHLCV_CANDLES : "priced by"
MARKET_INSTRUMENTS ||--o{ OPTION_QUOTES : "quoted by"
MARKET_INSTRUMENTS ||--o{ ORDERS : "traded as"
MARKET_INSTRUMENTS ||--o{ POSITIONS : "held as"
MARKET_INSTRUMENTS ||--o{ TRADES : "traded as"
OPTION_CHAIN_SNAPSHOTS ||--o{ OPTION_QUOTES : "contains"
ORDERS ||--o{ EXECUTIONS : "filled by"
ORDERS ||--o{ TRADES : "results in"
TRADES ||--o| TRADE_JOURNAL : "explained by"
MONTHLY_TARGETS {
uuid id PK
smallint year UK
smallint month UK
numeric target_amount
varchar currency
varchar calculation_mode
}
MONTHLY_TARGET_HISTORY {
uuid id PK
uuid monthly_target_id FK
numeric previous_amount "null on first set"
numeric new_amount
timestamptz changed_at
varchar source
}
DAILY_PERFORMANCE {
uuid id PK
date trading_date UK
numeric realized_pnl
numeric unrealized_pnl
numeric fees
numeric net_pnl
numeric daily_target "the goal in force that day"
int trade_count
int win_count
int loss_count
int breakeven_count
varchar source
}
TRADING_SESSIONS {
uuid id PK
date trading_date UK
varchar market UK
timestamptz started_at
timestamptz ended_at
varchar status
}
MARKET_INSTRUMENTS {
uuid id PK
varchar exchange UK
varchar symbol UK
varchar segment
varchar underlying
varchar instrument_type
date expiry
numeric strike_price
varchar option_type
boolean active
varchar source
}
OHLCV_CANDLES {
timestamptz timestamp PK "hypertable, 7-day chunks"
uuid instrument_id PK,FK
varchar timeframe PK
varchar source PK
numeric open
numeric high
numeric low
numeric close
bigint volume
bigint open_interest
}
INDIA_VIX {
timestamptz timestamp PK "hypertable, 30-day chunks"
varchar source PK
numeric open
numeric high
numeric low
numeric close
}
OPTION_CHAIN_SNAPSHOTS {
uuid id PK
timestamptz captured_at UK
varchar underlying UK
date expiry UK
varchar source UK
numeric spot_price
}
OPTION_QUOTES {
timestamptz timestamp PK "hypertable, 1-day chunks"
uuid instrument_id PK,FK
varchar source PK
uuid snapshot_id FK
numeric last_price
numeric bid_price
numeric ask_price
bigint open_interest
numeric implied_volatility
numeric delta
numeric gamma
numeric theta
numeric vega
numeric rho
}
ORDERS {
uuid id PK
varchar broker
varchar broker_order_id
varchar client_order_id
uuid instrument_id FK
varchar side
varchar order_type
int quantity
numeric price
varchar status
jsonb broker_raw_payload
}
EXECUTIONS {
uuid id PK
uuid order_id FK
varchar broker_execution_id UK
int quantity
numeric price
numeric fees
timestamptz executed_at
}
TRADES {
uuid id PK
uuid order_id FK
uuid instrument_id FK
varchar side
int quantity
numeric entry_price
numeric exit_price
numeric gross_pnl
numeric fees
numeric net_pnl
}
POSITIONS {
uuid id PK
uuid instrument_id FK
int quantity
numeric average_entry_price
numeric realized_pnl
numeric unrealized_pnl
varchar status
}
TRADE_JOURNAL {
uuid id PK
uuid trade_id FK,UK
varchar market_regime
varchar signal_type
numeric signal_confidence
text entry_reason
text exit_reason
jsonb diagnostics
}
In use today — written and read by the running application:
monthly_targets · monthly_target_history · daily_performance ·
trading_sessions · market_instruments · ohlcv_candles · india_vix
Defined but empty — the structural landing ground for later phases:
orders · executions · trades · positions · option_chain_snapshots ·
option_quotes · trade_journal
Why define them now rather than when they are needed? Because adding an order table to a system that already has months of P&L history means backfilling relationships that were never recorded. The tables cost nothing empty, and their presence forces the foreign-key shape to be thought through once, while it is cheap to change.
Why they are empty and not seeded: an orders row is a claim that an
order was placed. There is no broker, so there was no order, and inventing one
would put a fabricated execution record in the same table a real one will
later occupy — with no way for a future reader to tell them apart.
The user's stated goal for a calendar month, and the reason Phase 3 exists.
One row per (year, month), enforced by a unique constraint.
calculation_mode records how the daily figure was derived —
calendar_days_30 today. Storing it means a later switch to "remaining NSE
sessions" does not silently reinterpret historical targets.
Append-only audit of every change. previous_amount is NULL for the first
value, because there was nothing before it.
A no-op edit writes nothing. Saving ₹15,000 over ₹15,000 is not a change, and an audit log full of "₹15,000 became ₹15,000" is one nobody reads when it matters.
One row per trading date — the journal the dashboard renders.
daily_target is stored per row rather than derived on read. That looks like
denormalisation and is deliberate: it records the goal that was actually in
force on that date. Deriving it from today's monthly target would silently
rewrite history every time the user changed their goal, making a day that was
missed look achieved.
Counts are constrained so outcomes cannot outnumber attempts
(win + loss + breakeven <= trade_count), which catches a whole class of
aggregation bug at write time rather than on a dashboard.
Absence is meaningful: a date with no row means no trading, which is a different fact from traded and broke even. Collapsing the two would corrupt the win-rate denominator.
The tradable-symbol registry, keyed (exchange, symbol). Everything that
refers to an instrument does so by UUID, so a symbol rename does not orphan
history.
Hypertables. Composite primary keys lead with timestamp, which is a hard
TimescaleDB requirement rather than a style choice — a unique index on a
hypertable must contain the partitioning column, because uniqueness is only
enforced within a chunk.
source is part of the primary key. Two providers quoting the same instrument
at the same instant is normal, and they must not silently overwrite each other.
| Table | Chunk interval | Reasoning |
|---|---|---|
option_quotes |
1 day | Heaviest write path — a full chain, repeatedly, intraday |
ohlcv_candles |
7 days | Moderate volume; weekly chunks keep the chunk count sane |
india_vix |
30 days | One series, low frequency — daily chunks would be mostly empty |
Default indexes are not created. All three tables already carry
timestamp as the leading primary-key column, so Timescale's default index
would duplicate the PK index — paying storage and per-insert maintenance for
nothing — and, being created behind Alembic's back, would be proposed for
deletion by the next --autogenerate run.
Deliberately separate from trades, one-to-one. A trade is a financial fact
and its columns are settled. A diagnostic note is an evolving research
artefact. Mixing them would mean schema churn on the table that must be most
stable, so the volatile half lives here with a jsonb column for structured
diagnostics.
NUMERIC(20, 4) in the database, Decimal in Python, end to end. Binary
floating point cannot represent 0.10; a figure that drifts a paisa per
operation is a defect that compounds silently and is very hard to trace once
it has.
Rows can be generated before they are inserted and merged from more than one producer without a sequence round-trip. The seed exploits this directly: its keys are UUIDv5 values derived from the natural key, so re-running produces the same identifiers rather than new ones.
One strategy, used everywhere. The Python StrEnum is the single source of
truth and the migration writes a matching CHECK constraint.
Not native CREATE TYPE ... AS ENUM, because adding a value to a PostgreSQL
enum is a schema migration that cannot run inside a transaction on older
servers and is awkward to reverse. Not an unconstrained VARCHAR, because
then nothing stops "BUYY" from being stored. A CHECK gives the database's
guarantee with an ALTER that is a one-line constraint swap.
No cascades anywhere. Deleting an instrument must not silently take months of
candles with it, and a CASCADE on financial history is a data-loss incident
waiting for someone to run the wrong DELETE. Restricting means such a delete
fails loudly and the operator decides what actually should happen.
There is no DELETE endpoint of any kind, and no POST /reset-database.
Stored in UTC, rendered in Asia/Kolkata. A naive timestamp in a trading
system is an unanswered question about which 09:15 is meant.
source on the row (development_seed, later nse) and source on the API
envelope (mock, database). They answer different questions: the envelope
says where the service read the figure from, the column says where the
data came from. The account endpoint reports source: mock even with a
database present, because there is no broker and therefore no real balance —
a row in a database reads as authoritative in a way a literal in a mock module
does not.
Alembic, async, with the URL taken from application settings so no password is ever written to a committed file.
.venv/Scripts/python -m alembic upgrade head # apply
.venv/Scripts/python -m alembic downgrade -1 # revert one
.venv/Scripts/python -m alembic revision --autogenerate -m "..."downgrade() drops the tables it created but deliberately does not drop
the timescaledb extension: the extension is database-wide and may be in use
by something this migration knows nothing about.
Autogenerate filters out TimescaleDB's internal chunk tables. Without that filter every run would propose dropping them — which would delete market data.
.venv/Scripts/python -m app.db.seed # current month
.venv/Scripts/python -m app.db.seed --year 2026 --month 9Development only. Four properties, each tested:
- Deterministic — values come from integer arithmetic over the day index, not a PRNG, so they are identical across Python versions and platforms and a reader can verify any single figure by hand.
- Idempotent —
ON CONFLICTeverywhere. Running it twice converges rather than accumulating, and the audit log does not grow. - Non-destructive — the monthly target is inserted
ON CONFLICT DO NOTHING, notDO UPDATE, so a developer's own edited target survives the next seed run. The more obvious upsert would present as "the dashboard keeps forgetting my target". - Marked — every row carries
source = 'development_seed'.
It writes only elapsed weekdays of the month. Never a future date: a result recorded for a day that has not happened is indistinguishable in shape from a look-ahead bug. NSE holidays are not excluded, because no holiday calendar is ingested in this phase and inventing one would bake a guess into a database.
| Endpoint | Consults the database? | Behaviour when it is down |
|---|---|---|
GET /health/live |
No | 200 alive |
GET /health/ready |
Yes | 503, database.state = down |
GET /health |
Yes | 503, status = degraded |
Liveness never touches the database — on purpose. Restarting the application cannot fix a database that is down; it only removes the capacity that would have recovered on its own, turning a brief dependency blip into an outage of the service itself.
Database-backed endpoints return 503, not 500:
{
"error": {
"code": "DATABASE_UNAVAILABLE",
"message": "The service is temporarily unable to reach its data store. No data has been changed. Please retry shortly."
}
}503 says "this request would have worked, try again", which is true and is what a load balancer, a retry policy and an orchestrator all key on. 500 says the service is broken and a retry is pointless.
The message is fixed. A SQLAlchemy or asyncpg exception string routinely carries host, port, database name, username, and sometimes the failing SQL with its bound parameters — which for this schema means monetary values. A real one looks like:
connection to server at "10.0.3.14", port 5432 failed:
FATAL: password authentication failed for user "trading_agent"
That is a topology and a valid username in one line, and a health endpoint is typically the least protected route in a system. The full exception goes to the log; the client gets a constant sentence and a stable code.
There is no fallback to mock data. A dashboard showing an invented ₹4,690 because the database was unreachable is worse than one showing an error: the user would act on a number that describes nothing and have no way to tell.
Two failure shapes are handled, not one. A refused TCP connection surfaces as
a bare ConnectionRefusedError from the event loop's socket layer — before
asyncpg has a DBAPI error to wrap and before SQLAlchemy can turn it into an
OperationalError. Handling only SQLAlchemyError let that escape as an
unhandled 500 with a traceback, which was found by stopping the container and
is now covered by tests.
Recovery needs no restart: pool_pre_ping=True validates a pooled connection
before handing it out, so the first request after the database returns
reconnects transparently.
cd backend
.venv/Scripts/python -m pytest # unit + integration
.venv/Scripts/python -m pytest tests/integration # database onlyIntegration tests create and destroy a separate trading_agent_test database
and build its schema by running the real migrations — not
Base.metadata.create_all, which would test the models against themselves and
prove nothing about the artefact that actually ships.
With no database reachable they skip, with the reason attached, rather than failing — a suite that is red on a laptop without Docker trains people to ignore red. They never touch the development database.
What they prove, beyond the repositories:
- The monthly target survives a restart — asserted by reading it back from a genuinely separate Python process, not a rebuilt app object in the same interpreter, which would leave the engine and every singleton intact and let an in-memory value pass.
- The seed is idempotent and non-destructive across real commits.
- Month-range queries include both boundaries and roll December into January.
- The database-down matrix, by pointing the engine at a dead port: 503 from the data endpoints, 200 from liveness, 200 mock from the account endpoint, and no host, port, username or driver text in any response body.
Durability is on a named volume (nifty_agent_postgres_data), not in the
container. Verified by running docker compose down, which removes the
container entirely, then up -d: the monthly target, all seeded sessions and
the Timescale chunk layout were intact afterwards.
docker compose down -v deletes everything. The -v removes the named
volume. That is the only supported way to reset, and it is deliberately not
exposed through the API.
First connection is slow. The engine is created lazily, so the first probe
in a process pays for the whole connection establishment — TCP,
authentication, and asyncpg's type-codec setup. Measured on the development
stack: ~2,170 ms cold, ~5 ms once pooled. DB_HEALTH_TIMEOUT is 5.0 s for
that reason; an earlier 2.0 s budget reported a perfectly healthy database as
timeout on the first request, which is the worst kind of health check — one
that fails when nothing is wrong. Much of the cold cost is the WSL localhost
relay; a native Linux host connects considerably faster.
On WSL, the virtual machine shuts down after a period of idleness and takes
the database with it, which presents as a sudden ConnectionRefusedError on a
stack that was working minutes ago. docker compose up -d brings it back.
To avoid it recurring, set vmIdleTimeout in %UserProfile%\.wslconfig.
The engine is not opened at startup. An unreachable database therefore
surfaces as failing requests with a real error and a healthy /health/live,
rather than as a process that refuses to boot — which is far harder to
diagnose from the outside.