trading-desk 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trading_desk-0.3.0/LICENSE +21 -0
- trading_desk-0.3.0/PKG-INFO +577 -0
- trading_desk-0.3.0/README.md +552 -0
- trading_desk-0.3.0/desk/__init__.py +0 -0
- trading_desk-0.3.0/desk/__main__.py +176 -0
- trading_desk-0.3.0/desk/book.py +2308 -0
- trading_desk-0.3.0/desk/check.py +684 -0
- trading_desk-0.3.0/desk/config.py +225 -0
- trading_desk-0.3.0/desk/migrate.py +338 -0
- trading_desk-0.3.0/desk/parse.py +929 -0
- trading_desk-0.3.0/desk/prices.py +375 -0
- trading_desk-0.3.0/desk/schema.sql +734 -0
- trading_desk-0.3.0/desk/tui/__init__.py +0 -0
- trading_desk-0.3.0/desk/tui/app.py +757 -0
- trading_desk-0.3.0/desk/tui/app.tcss +109 -0
- trading_desk-0.3.0/desk/tui/charts.py +121 -0
- trading_desk-0.3.0/desk/tui/footer.py +124 -0
- trading_desk-0.3.0/desk/tui/keymap.py +234 -0
- trading_desk-0.3.0/desk/tui/panes.py +2166 -0
- trading_desk-0.3.0/desk/tui/prompt.py +197 -0
- trading_desk-0.3.0/desk/tui/themes.py +210 -0
- trading_desk-0.3.0/desk/tui/widgets.py +91 -0
- trading_desk-0.3.0/pyproject.toml +94 -0
- trading_desk-0.3.0/setup.cfg +4 -0
- trading_desk-0.3.0/tests/test_book.py +488 -0
- trading_desk-0.3.0/tests/test_desk.py +5268 -0
- trading_desk-0.3.0/tests/test_schema.py +323 -0
- trading_desk-0.3.0/trading_desk.egg-info/PKG-INFO +577 -0
- trading_desk-0.3.0/trading_desk.egg-info/SOURCES.txt +31 -0
- trading_desk-0.3.0/trading_desk.egg-info/dependency_links.txt +1 -0
- trading_desk-0.3.0/trading_desk.egg-info/entry_points.txt +4 -0
- trading_desk-0.3.0/trading_desk.egg-info/requires.txt +1 -0
- trading_desk-0.3.0/trading_desk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zeying Tian
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trading-desk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: A trading book in the terminal: SQLite, a Textual TUI, and a checker that will not let the record contradict itself
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/IngTian/trading-desk
|
|
7
|
+
Project-URL: Repository, https://github.com/IngTian/trading-desk
|
|
8
|
+
Project-URL: Issues, https://github.com/IngTian/trading-desk/issues
|
|
9
|
+
Keywords: tui,terminal,textual,trading,portfolio,bookkeeping,sqlite
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: textual<9,>=8.2
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# trading-desk
|
|
27
|
+
|
|
28
|
+
A terminal bookkeeping tool for your own trading: a SQLite book, a Textual TUI over it, and
|
|
29
|
+
a checker that refuses to let the record contradict itself.
|
|
30
|
+
|
|
31
|
+

|
|
32
|
+
|
|
33
|
+
<details>
|
|
34
|
+
<summary>positions and bets</summary>
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
</details>
|
|
41
|
+
|
|
42
|
+
Every figure above is invented. The screenshots are rendered by `tools/screenshots.py` from
|
|
43
|
+
the same synthetic book the test suite runs against, so they cannot publish a real position
|
|
44
|
+
and cannot drift from a state the tests already cover — re-run it after any change that
|
|
45
|
+
moves a column.
|
|
46
|
+
|
|
47
|
+
**This repo is the TOOL. Neither your record nor your writing is in it.**
|
|
48
|
+
|
|
49
|
+
Data is data and code is code. A repo holding both is one you cannot share, cannot
|
|
50
|
+
publish, and cannot check out twice — and a trading thesis is neither, so it gets a third
|
|
51
|
+
place rather than being wedged into one of the first two.
|
|
52
|
+
|
|
53
|
+
So there are three things and they live in three places:
|
|
54
|
+
|
|
55
|
+
| | what | where |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| **the tool** | this repo — schema, TUI, checks | git, shareable, no positions in it |
|
|
58
|
+
| **the book** | accounts, fills, flows, bets | `$TRADING_DESK_BOOK`, wherever you back up |
|
|
59
|
+
| **the writing** | theses, observations, patterns | its own repo, in Obsidian. **This tool never touches it** |
|
|
60
|
+
|
|
61
|
+
## What this is, and what it is not
|
|
62
|
+
|
|
63
|
+
**It is a bookkeeping tool.** It records what was done, and nothing is built on top of
|
|
64
|
+
the record yet.
|
|
65
|
+
|
|
66
|
+
It records, it shows, and it refuses to state what it does not know. **It enforces
|
|
67
|
+
nothing.** No risk rules, no limits, no verdicts on a trade: it links bets to positions and
|
|
68
|
+
prints what is there.
|
|
69
|
+
|
|
70
|
+
That is a conclusion about mechanics rather than a preference. The broker is somewhere else
|
|
71
|
+
and this book is read afterwards, so nothing here could ever stop a trade — which made
|
|
72
|
+
"LOSS LIMIT BREACHED, close the portfolio and step away" a sentence a text file said to
|
|
73
|
+
someone who had already decided. A wall that cannot hold is a wall in name only, and
|
|
74
|
+
calling it one made every figure around it read as permission. A limit you hold yourself to
|
|
75
|
+
needs no column; one you do not is not made real by storing it.
|
|
76
|
+
|
|
77
|
+
What survives are **integrity** constraints, which are a different thing: a closed trade
|
|
78
|
+
cannot hold shares, an audit log cannot be edited, a fill cannot take a position short.
|
|
79
|
+
Those keep the record from stating a contradiction. They do not judge the trading.
|
|
80
|
+
|
|
81
|
+
`desk/schema.sql` is the record's integrity as constraints: 16 tables, 22 triggers,
|
|
82
|
+
2 views and 9 indexes, and the comment on each one says which failure produced it. Whatever
|
|
83
|
+
discipline you trade by stays where you write, not in here — the tool has no opinion on it
|
|
84
|
+
and no way to check it.
|
|
85
|
+
|
|
86
|
+
Named a desk rather than a hub because that is the actual noun: a desk is where a
|
|
87
|
+
book, its views and its risk limits live, and it allocates to pods — which is how
|
|
88
|
+
bets work here.
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
book/
|
|
92
|
+
├── schema.sql THE SCHEMA. Source of truth, and it is CODE
|
|
93
|
+
├── migrate.py --new to create a book, --check to detect drift
|
|
94
|
+
├── check.py assert the invariants; exits non-zero if wrong
|
|
95
|
+
└── prices.py pull closes and FX
|
|
96
|
+
desk/
|
|
97
|
+
├── config.py WHERE THE BOOK IS. $TRADING_DESK_BOOK
|
|
98
|
+
├── book.py all the arithmetic, no Textual
|
|
99
|
+
├── parse.py the one-line grammar
|
|
100
|
+
└── tui/ four tabs: dashboard, positions, capital, bets
|
|
101
|
+
tests/
|
|
102
|
+
├── test_desk.py the CODE, against a synthetic book. Needs no data
|
|
103
|
+
├── test_book.py the RECORD. Skips when there is none
|
|
104
|
+
├── test_schema.py schema.sql and migrate.py, incl. the drift test
|
|
105
|
+
└── seed.py the synthetic book, built from schema.sql
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Install
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
pip install trading-desk # or: uv tool install trading-desk
|
|
112
|
+
desk # it offers to create a book, and says where
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Three commands are installed. `desk` is the whole tool; the other two exist because a book
|
|
116
|
+
has to come from somewhere and has to be checkable:
|
|
117
|
+
|
|
118
|
+
| | |
|
|
119
|
+
|---|---|
|
|
120
|
+
| `desk` | the TUI. Everything is done in here |
|
|
121
|
+
| `desk-migrate --new` | create a book. `--check` compares one against the schema, `--upgrade` rebuilds it at the current version carrying every row |
|
|
122
|
+
| `desk-check` | the invariants, then an attribution panel. `--quiet` for invariants only; exits non-zero if one fails |
|
|
123
|
+
|
|
124
|
+
**The first run offers to create a book**, printing the path it would use and what chose
|
|
125
|
+
that path before it writes anything — four things can decide it, and a mistyped
|
|
126
|
+
`$TRADING_DESK_BOOK` would otherwise make a second empty book in silence. Answer `n` and set
|
|
127
|
+
the variable if the path is not the one you want. `desk-migrate --new` does the same thing
|
|
128
|
+
without the question.
|
|
129
|
+
|
|
130
|
+
A fresh book has NO ACCOUNTS and every write lands in one, so open a sleeve first: `a` on
|
|
131
|
+
the dashboard, then `tfsa default`.
|
|
132
|
+
|
|
133
|
+
From a checkout instead:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
uv sync
|
|
137
|
+
uv run desk-migrate --new
|
|
138
|
+
uv run desk
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**An unknown ticker registers itself.** Type a fill for a symbol the book has never seen
|
|
142
|
+
and it is looked up at the price source, which supplies the currency, the type, the name
|
|
143
|
+
and the quote symbol — so `ZGLD` becomes `ZGLD.TO` without your having to know that.
|
|
144
|
+
The registry is still a wall against typos; it is just a better one, because
|
|
145
|
+
`NOSUCHTICKER` does not exist anywhere rather than merely not existing here.
|
|
146
|
+
|
|
147
|
+
**There is no instrument key**, and the one there was is gone. Nobody should have to
|
|
148
|
+
register a ticker before recording that they bought it. A currency to translate with and a
|
|
149
|
+
symbol to fetch by are facts the TOOL needs, and not one of them is a decision about
|
|
150
|
+
trading — so the tool fetches them itself and stops asking.
|
|
151
|
+
|
|
152
|
+
The one case a lookup genuinely cannot settle is an **ambiguous** ticker, which is not rare
|
|
153
|
+
— `CBIL` is *Corgi 3-12 Month T-Bill* in New York and *Global X 0-3 Month T-Bill* in
|
|
154
|
+
Toronto, and `INTC` and `TQQQ` have the same shape. So the fill prompt becomes a `listing`
|
|
155
|
+
prompt: the toast names which security each candidate is, `tab` **cycles** them, and enter
|
|
156
|
+
replays the fill you already typed. It cycles rather than completes because the candidates
|
|
157
|
+
share a prefix by construction, so prefix completion would insert nothing and read as a
|
|
158
|
+
dead key. Nothing is written until you pick, and escaping writes nothing at all.
|
|
159
|
+
|
|
160
|
+
Leverage is the case that got dropped instead. It is not in the metadata — Yahoo calls
|
|
161
|
+
SOXL a plain ETF — and it was only ever used to warn, which is a rule, and there are no
|
|
162
|
+
rules left.
|
|
163
|
+
|
|
164
|
+
An account is just a NAME. It carried a `kind` from a Canadian tax taxonomy and a single
|
|
165
|
+
`currency` until v11, and both went — nothing read the kind, and one currency per sleeve
|
|
166
|
+
is false when a TFSA holds CAD and USD at once. So `a` takes an id, an optional `~name`,
|
|
167
|
+
and the bare word `default`.
|
|
168
|
+
|
|
169
|
+
**No configuration is needed.** The book defaults to
|
|
170
|
+
`~/Documents/trading-desk/hub.db` and `--new` creates the folder. Nothing goes in your
|
|
171
|
+
shell profile and nothing goes in this repo — the shape is borrowed from `daylogs`,
|
|
172
|
+
which defaults to `~/Documents/daylogs/` for the same reason.
|
|
173
|
+
|
|
174
|
+
To put the book somewhere else:
|
|
175
|
+
|
|
176
|
+
| | |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `$TRADING_DESK_HOME` | move the whole data root |
|
|
179
|
+
| `book = "..."` in `<root>/config.toml` | move just the book. **Config lives with the DATA**, never in this repo |
|
|
180
|
+
| `$TRADING_DESK_BOOK` | one book, absolute. Beats the config file, so `TRADING_DESK_BOOK=/tmp/x desk` is a safe one-off |
|
|
181
|
+
|
|
182
|
+
The same `config.toml` holds the **profile** — your name and your home currency:
|
|
183
|
+
|
|
184
|
+
```toml
|
|
185
|
+
name = "Your Name"
|
|
186
|
+
home_currency = "CAD" # CAD or USD; anything else falls back to CAD and says so
|
|
187
|
+
theme = "nord" # any Textual built-in; an unknown name falls back, silently
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`theme` is the one key the app itself writes: `t` opens a picker you arrow through with the
|
|
191
|
+
interface live underneath, and `enter` saves your choice here. It is a config key as well as a
|
|
192
|
+
key binding because *"gruvbox"* versus *"nord"* is unguessable from a file — and a wrong guess
|
|
193
|
+
otherwise costs a restart to see.
|
|
194
|
+
|
|
195
|
+
Both have defaults (`desk` and `CAD`), so an absent file is a supported state. The home
|
|
196
|
+
currency is what every figure on the dashboard is quoted in; changing it re-denominates
|
|
197
|
+
the page and rewrites nothing, because cash is stored per currency and only the current
|
|
198
|
+
balance is ever translated.
|
|
199
|
+
|
|
200
|
+
`?` names which book is in use and **why** that one — four things can decide the
|
|
201
|
+
path, and when a figure looks wrong the first question is which book produced it.
|
|
202
|
+
|
|
203
|
+
**Use the project's environment, not your system Python.** The TUI needs Textual,
|
|
204
|
+
which is declared in `pyproject.toml` and installed in `.venv`. `uv run` handles it;
|
|
205
|
+
`.venv/bin/python -m desk` works too. A bare `python -m desk` will fail with
|
|
206
|
+
`ModuleNotFoundError: No module named 'textual'` if your `python` is a system or
|
|
207
|
+
conda one — and it now says so with the command to use instead.
|
|
208
|
+
|
|
209
|
+
**`desk` TAKES NO ARGUMENTS.** There were seven subcommands — `positions`, `balance`,
|
|
210
|
+
`marks`, `where`, `account`, `instrument`, `asof` — and each is now a key:
|
|
211
|
+
|
|
212
|
+
| gone | now |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `desk positions` | tab `2` |
|
|
215
|
+
| `desk balance` | the dashboard, tab `1` — and **it is computed**, see below |
|
|
216
|
+
| `desk marks` | `m`, which also runs itself every 60 seconds |
|
|
217
|
+
| `desk where` | `?` |
|
|
218
|
+
| `desk account add` | `a` on the dashboard |
|
|
219
|
+
| `desk instrument add` | **nothing.** A fill registers its own ticker — see below |
|
|
220
|
+
| `desk asof` | **nothing.** The as-of clock chose between rule versions, and there are no rules left to choose between |
|
|
221
|
+
|
|
222
|
+
Two surfaces meant two answers: `desk balance` had its own printer and its own early
|
|
223
|
+
return, which dropped output when there was no snapshot — a bug that existed only because
|
|
224
|
+
the same figures were rendered twice. Passing a removed name still tells you where it
|
|
225
|
+
went. Everything under `book/` is unchanged and needs no Textual.
|
|
226
|
+
|
|
227
|
+
A fresh book has the schema and two singleton rows. **No accounts and no instruments.**
|
|
228
|
+
Accounts are yours to name, which is why `a` is a key rather than a seeded row — and it
|
|
229
|
+
needs to be a key, because the schema is STRICT and there is a partial unique index on the
|
|
230
|
+
default account, and hand-written SQL gets that wrong. Instruments arrive on their own,
|
|
231
|
+
with the first fill that names one.
|
|
232
|
+
|
|
233
|
+
There is no in-repo fallback. There was one while the database was being moved out,
|
|
234
|
+
and keeping it would now be a trap: it would silently prefer a stale book left in a
|
|
235
|
+
checkout over the real one at the default path.
|
|
236
|
+
|
|
237
|
+
**There is no vault setting, and no vault code.** There was, for a few hours, and it
|
|
238
|
+
went: the tool stays on the book, and the writing is handled by whatever you already write
|
|
239
|
+
in.
|
|
240
|
+
|
|
241
|
+
So this tool does not read the vault, write to it, create folders in it, scaffold a
|
|
242
|
+
thesis from a template, or have any opinion about whether a bet's claim exists. A tool
|
|
243
|
+
that scaffolds someone else's writing has an opinion about their workflow.
|
|
244
|
+
|
|
245
|
+
## The bet's name is the whole interface
|
|
246
|
+
|
|
247
|
+
**One string is shared between the book and the writing: the bet's id.**
|
|
248
|
+
|
|
249
|
+
The book records the name you typed and not the path it lives at, and that is what makes
|
|
250
|
+
the two repos genuinely independent rather than nominally so.
|
|
251
|
+
|
|
252
|
+
You register `ai-capex-semis` in the book — `4` then `n`, then
|
|
253
|
+
`ai-capex-semis =23000 ~one line if you want one`. You write the thesis wherever you
|
|
254
|
+
write theses, under the same name. Nothing links them but the name, and **nothing in
|
|
255
|
+
this repo tries to.**
|
|
256
|
+
|
|
257
|
+
Schema v9 dropped `notes_ref`, `notes_rev` and `notes_sha256` from every table that
|
|
258
|
+
had them. That is not tidiness: every pointer problem this repo has had came from
|
|
259
|
+
storing a path. Seven rotted during one repo move, all of them needed rewriting when
|
|
260
|
+
the writing was split into its own repo, and the desk had grown a vocabulary —
|
|
261
|
+
`linked` / `edited` / `ROTTED` — whose only job was describing how stale a string had
|
|
262
|
+
become. There is no pointer now, so there is nothing to rot.
|
|
263
|
+
|
|
264
|
+
The id is validated as a slug — lowercase words, single hyphens — because it is the
|
|
265
|
+
one string you will retype somewhere else. Case is normalised rather than refused:
|
|
266
|
+
`AI-CAPEX` gets you `ai-capex`, since *"was it capitalised?"* is a worse thing to
|
|
267
|
+
have to remember than a rule about hyphens.
|
|
268
|
+
|
|
269
|
+
## What the database refuses, and what it does not
|
|
270
|
+
|
|
271
|
+
One layer, and it is about integrity rather than about trading:
|
|
272
|
+
|
|
273
|
+
| layer | where | state |
|
|
274
|
+
|---|---|---|
|
|
275
|
+
| **Integrity** | 22 triggers and ~100 `CHECK` clauses **inside `hub.db`** | **enforced.** No client can bypass them, including raw `sqlite3`. They stop the record from stating a contradiction: a closed trade holding shares, a fill taking a position short, an edited audit row |
|
|
276
|
+
| **Trading rules** | nowhere | **there is no second layer.** Nothing evaluates a rule, judges a trade against one, or reports on compliance |
|
|
277
|
+
|
|
278
|
+
There was a second layer — `rules`, `rule_versions`, `deviations`, `principles`, a stop
|
|
279
|
+
curve derived from each leg's share of its bet, and a verdict on every stop that missed it.
|
|
280
|
+
All of it is gone. Two earlier copies went the same way for the same reason: a `rules.yaml`
|
|
281
|
+
that had drifted to 16 rules against the database's 21 with no code reading either, and a
|
|
282
|
+
prose ruleset that described machinery nothing ran.
|
|
283
|
+
|
|
284
|
+
The pattern is worth stating because it repeated three times: a rule that cannot be
|
|
285
|
+
enforced becomes a rule that is merely *stored*, and a stored rule drifts from the one you
|
|
286
|
+
actually follow while still looking authoritative on screen.
|
|
287
|
+
|
|
288
|
+
## One rule the design turns on
|
|
289
|
+
|
|
290
|
+
**NULL means NOT RECORDED. Never zero, never parity, never a sensible default.** It is
|
|
291
|
+
the reason the schema allows NULL where it does, and it is the reason `book.py` returns
|
|
292
|
+
`None` and the panes print an em dash instead of a number.
|
|
293
|
+
|
|
294
|
+
Every figure on the dashboard is withheld rather than estimated, and the reason is printed
|
|
295
|
+
under it. This is not fastidiousness — it is the defect this codebase has shipped most
|
|
296
|
+
often. A cost basis summed as `p.cost_base or 0.0` let a leg with no entry price contribute
|
|
297
|
+
nothing to cost and its whole value to mark, and advertised **+11,760.18 (+64.30%)** on a
|
|
298
|
+
book whose real open-leg P&L was **+255.58**. A USD leg valued at par in a CAD book is
|
|
299
|
+
wrong by the whole factor, about 38%, and looks like an ordinary number doing it.
|
|
300
|
+
|
|
301
|
+
A partial total is not a conservative estimate. It is a wrong number that reads as a right
|
|
302
|
+
one.
|
|
303
|
+
|
|
304
|
+
## Where the net comes from
|
|
305
|
+
|
|
306
|
+
It is **computed**, not typed. You record deposits, fees, small adjustments and trades;
|
|
307
|
+
the total is derived from those and should land within rounding of what the broker shows. A
|
|
308
|
+
total you type yourself is a total you have to maintain, and it goes stale without saying
|
|
309
|
+
so.
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
net = cash + positions at their marks
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Five things move cash and there is no sixth: a deposit or withdrawal (`c`), the offset row
|
|
316
|
+
(`o`), dividends and interest, a currency conversion, and a trade (`n`). Each lives in its
|
|
317
|
+
own table and none overlaps another, so it is a sum rather than a reconciliation.
|
|
318
|
+
|
|
319
|
+
**Each currency is held native, and only the current balance is translated.** That needs
|
|
320
|
+
no FX history at all, and it is also the only way it is right: translating every past
|
|
321
|
+
movement at today's rate cancels the FX gain on the principal, because the cash that left
|
|
322
|
+
and the position that arrived are the same figure in the same currency. On a US$10,000
|
|
323
|
+
sleeve a 3% move is roughly C$410 of real P&L that the naive version loses — the figure
|
|
324
|
+
scales with the sleeve, so on a larger one the error is proportionally larger.
|
|
325
|
+
|
|
326
|
+
`contributed` is the exception — translated at the rate on the deposit's **own** date,
|
|
327
|
+
because what you put in is history and must not move because the loonie did.
|
|
328
|
+
|
|
329
|
+
Offsets are how dividends, fees and interest reach the net without a table each.
|
|
330
|
+
Individually they are too small to be worth a schema, and one reconciling entry against the
|
|
331
|
+
account balance is good enough while the difference is small. An offset moves **cash** and
|
|
332
|
+
never contributed capital —
|
|
333
|
+
recording a dividend as a deposit would hide exactly that much return.
|
|
334
|
+
|
|
335
|
+
The four parts **sum to the return exactly**, and `Net.ties()` is checked before any of
|
|
336
|
+
them prints. Getting that right needed a specific construction: the obvious one — realised
|
|
337
|
+
from closed trades, unrealised from cost basis — misses, because a trim on a *still-open*
|
|
338
|
+
trade belongs to neither bucket. So trading P&L is `traded + positions`, which needs no
|
|
339
|
+
decision about which shares were sold, and realised is the remainder after unrealised.
|
|
340
|
+
Tab 2 names the difference as "banked in open" rather than letting two totals look like a
|
|
341
|
+
bug in one of them.
|
|
342
|
+
|
|
343
|
+
## Bets
|
|
344
|
+
|
|
345
|
+
A **bet** is allocation plus belief: capital, a thesis, a falsifier. It carries no loss
|
|
346
|
+
limit, and neither do its legs. A stop is **recorded, not graded** — `s` on positions
|
|
347
|
+
writes down where you put it at the broker, and nothing compares that to a curve.
|
|
348
|
+
|
|
349
|
+
Tab 2 groups its legs **under their bet**, with an explicit "no bet" group for the
|
|
350
|
+
cash-parking, because the link between a bet and a trade is the thing this tool is for.
|
|
351
|
+
|
|
352
|
+
**ROWS ARE NATIVE, TOTALS ARE HOME.** A row once read `1 815.5100 815.56 … 1,126.04`,
|
|
353
|
+
where the prices were USD and the value was CAD and nothing said so. `1 × 815.56` is not
|
|
354
|
+
`1,126.04`, so the row was arithmetic nobody could follow.
|
|
355
|
+
|
|
356
|
+
Every leg row now states its currency in a `ccy` column and shows its value and P&L **in
|
|
357
|
+
that currency**, so the row multiplies out. Only the bet subtotals and the pane header
|
|
358
|
+
translate — they sum across currencies and have no choice — and they say `CAD` where they
|
|
359
|
+
do. Closed legs were already native and grouped by currency; the open ones simply joined
|
|
360
|
+
them, which is why one sentence now covers the whole table.
|
|
361
|
+
|
|
362
|
+
The percentage was free: `fx` cancels in a ratio, so the native and translated figures were
|
|
363
|
+
always the same number. And the native basis needs **no rate at all**, so a foreign leg the
|
|
364
|
+
FX table has not caught up with now shows its value, P&L and percentage instead of three
|
|
365
|
+
em-dashes — the book knew all three, and withholding what it knows is the same error as
|
|
366
|
+
inventing what it does not.
|
|
367
|
+
|
|
368
|
+
`value` and `P&L` carry no currency in their **headers**, deliberately: they hold the home
|
|
369
|
+
currency on a bet subtotal and the native one on every leg, so a label would be false for
|
|
370
|
+
half the table. The per-row `ccy` column is the only place that can be right for every row.
|
|
371
|
+
|
|
372
|
+
`tp` paid for the new column, and cost nothing. Nothing in the codebase ever wrote
|
|
373
|
+
`trades.target` — one read in `positions()` and no `UPDATE` anywhere — so all seven open
|
|
374
|
+
legs held NULL and the column rendered a stack of em-dashes no key could fill. Deleting an
|
|
375
|
+
unwritable reader is the cure chosen for the desk's other unwritable halves; a test now
|
|
376
|
+
asserts that a column and a writer arrive together or not at all.
|
|
377
|
+
|
|
378
|
+
**What a bet is made of** sits under the table, for the bet under the cursor: each ticker as
|
|
379
|
+
a share of the **declared allocation**, with a `(dry)` row for budget not yet spent. The
|
|
380
|
+
allocation is the denominator rather than the spend, and the `(dry)` row is why it matters — shares sum to
|
|
381
|
+
the bet's *filled* percentage, so the chart can say both "COIN is 11% of this bet" and "73% of
|
|
382
|
+
this bet is still cash". Share-of-the-spend always sums to 100% and can never say the second.
|
|
383
|
+
|
|
384
|
+
It costs nothing: `ranked_bars` derives its total from the values handed to it, so passing the
|
|
385
|
+
unspent budget as a row makes the allocation the denominator without the chart knowing. Costs
|
|
386
|
+
are aggregated **by ticker** (AEM.TO in both rrsp and tfsa is one holding) and translated to
|
|
387
|
+
the home currency — forced here, not chosen, since the comparison is against one
|
|
388
|
+
home-currency `allocation_base` and a bet holding USD and CAD legs has no common denominator
|
|
389
|
+
otherwise. A leg with no recorded cost is **named**, never counted as zero.
|
|
390
|
+
|
|
391
|
+
**`e` edits the thesis in `$EDITOR`** — the one key that leaves the desk. Every other write is
|
|
392
|
+
one line of grammar, which is the whole friction argument, but a thesis is three paragraphs
|
|
393
|
+
and 1,400 characters; a prompt that replaced it would destroy the reasoning to fix a typo in
|
|
394
|
+
it. Quitting without saving, or saving an identical file, is a no-op and stacks no undo.
|
|
395
|
+
|
|
396
|
+
**`R` renames a bet**, carrying every row that referenced it. There is no `ON UPDATE CASCADE`
|
|
397
|
+
anywhere in this schema — every foreign key to `bets(id)` is `ON DELETE CASCADE` only — so the
|
|
398
|
+
nine referring columns are repointed by hand, discovered from `PRAGMA foreign_key_list` rather
|
|
399
|
+
than a list that would rot on the next migration. `PRAGMA defer_foreign_keys` holds
|
|
400
|
+
enforcement until `COMMIT` so the brief inconsistency is legal and the end state is still
|
|
401
|
+
checked. Copy-then-delete was tried first and the triggers rejected it, rightly: inserting the
|
|
402
|
+
row under a new id fires `bet_live_needs_falsifier_ins`, a rule about *declaring* a bet.
|
|
403
|
+
Renaming in place fires only `ev_bets_upd`, which logs it. It is **not** undoable with `u` —
|
|
404
|
+
that stack holds one row in one table — and the toast says so; renaming back is the undo.
|
|
405
|
+
|
|
406
|
+
**Tab 3 lists the live ones only** — `forming`, `live`, `weakened`. A closed bet is
|
|
407
|
+
history and its realised P&L is already in the dashboard's return; what is left of it here
|
|
408
|
+
is one line saying how many finished and what they came to, so the money is never silently
|
|
409
|
+
dropped. Press `s` to change a status, which is how a bet leaves the list.
|
|
410
|
+
|
|
411
|
+
There used to be a curve, $\mathrm{clamp}(\mathrm{round}_5(100/\sqrt{s}), 10, 50)$ against
|
|
412
|
+
each leg's share of a frozen allocation, and a verdict on every stop that failed it. It
|
|
413
|
+
went with the rest of the enforcement: the tool cannot place a stop, so grading one was
|
|
414
|
+
commentary.
|
|
415
|
+
|
|
416
|
+
**`b` moves a leg between bets**, and it is an `UPDATE` on the trade rather than a rewrite of
|
|
417
|
+
its fills. That distinction is a bug, not a detail. The only way to re-point a leg used to be
|
|
418
|
+
`enter` on its newest fill with a different `!bet` — but `enter` edits by delete-then-add and
|
|
419
|
+
`add_fill` matches on `(bet, ticker, account)`, so the fill landed in a **new** trade and the
|
|
420
|
+
old one was left open with no fills — a phantom position, zero quantity, under no bet.
|
|
421
|
+
On a multi-fill leg it would have been worse — the newest fill moves, the rest strand
|
|
422
|
+
under the old bet, one position silently becoming two.
|
|
423
|
+
|
|
424
|
+
An open trade with no fills is something `check.py` rejects twice over ("an open trade with no
|
|
425
|
+
fills", "an open trade holding nothing"), and `x` could not clear it — with nothing left to
|
|
426
|
+
delete it just answered *"has no fills to delete"*. So the desk could write state its own
|
|
427
|
+
checker failed and offered no way out of it. `delete_fill` now takes an emptied **open** trade
|
|
428
|
+
with it, which closes both routes at once and makes `x` on a single-fill leg mean "delete this
|
|
429
|
+
position" without inventing a key for it. A `closed` trade is never removed — history must not
|
|
430
|
+
evaporate because a figure in it was corrected — and neither is a `planned` one, where having
|
|
431
|
+
no fills is the whole point. One `u` restores the trade and the fill together, because
|
|
432
|
+
`fills.trade_id` is a NOT NULL foreign key and the fill cannot go back without its parent.
|
|
433
|
+
|
|
434
|
+
**A position needs no bet.** Cash parked in a T-bill ETF has nothing to do with any bet,
|
|
435
|
+
so the requirement that every position have one was lifted. `trades.bet_id` is nullable, and the triggers that check it
|
|
436
|
+
are guarded with `WHEN NEW.bet_id IS NOT NULL`. An empty `b` prompt detaches a leg, which
|
|
437
|
+
makes it the one prompt in the desk that accepts a blank line rather than refusing it.
|
|
438
|
+
|
|
439
|
+
## The keys
|
|
440
|
+
|
|
441
|
+
Three tabs: **1** dashboard, **2** positions, **3** bets. Each records the thing it shows —
|
|
442
|
+
`n` a fill, `c` a deposit, `o` an offset, `n` a bet — plus `b` to move a leg between
|
|
443
|
+
bets. **There is no refresh key**: prices reload every 30 seconds and again the moment
|
|
444
|
+
holdings change, so pressing something to see a current number was a step that only existed
|
|
445
|
+
to be forgotten. Every write is one line of text, then enter; `tab` completes a ticker, a
|
|
446
|
+
`!bet` or a `/account`.
|
|
447
|
+
|
|
448
|
+
Capital was a fourth tab and is now a block on the dashboard. It carried too little to earn
|
|
449
|
+
a page, and deposits, withdrawals and what is parked are all inputs to net worth, so they
|
|
450
|
+
belong on the page that computes it.
|
|
451
|
+
|
|
452
|
+
**`?` is the authoritative list and this paragraph is not.** The footer, the bindings and
|
|
453
|
+
the `?` overlay are all generated from `desk/tui/keymap.py`, and a test asserts that no
|
|
454
|
+
footer entry names an unbound key and no prompt label lacks a hint. A hand-written table
|
|
455
|
+
in a README has no such test, which is exactly how it comes to advertise a key that was
|
|
456
|
+
removed — as this file did for `b` and `k` until v11.
|
|
457
|
+
|
|
458
|
+
## Use
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
desk-check # the invariants, then the panel
|
|
462
|
+
desk-check --quiet # invariants only
|
|
463
|
+
desk-check --schema # the live schema, generated from the database
|
|
464
|
+
pytest # the same invariants, plus schema properties
|
|
465
|
+
bash tests/schema_attack.sh # rebuild the schema into an empty db and attack it
|
|
466
|
+
|
|
467
|
+
sqlite3 "$(uv run python -c 'from desk import config; print(config.book_path())')"
|
|
468
|
+
# PRAGMA foreign_keys = ON; first, every time
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
**`desk/schema.sql` is the source of truth, and it is CODE.** For a while it was not —
|
|
472
|
+
the argument then was that SQLite stores every `CREATE` verbatim in `sqlite_master`,
|
|
473
|
+
comments and all, so the book already carried its own schema and a `.sql` file would be
|
|
474
|
+
a second copy. That reversed when the data moved out of the repo, and the reason is
|
|
475
|
+
simple: code that exists only inside someone's data file cannot be reviewed, diffed, or
|
|
476
|
+
used to build a fresh book.
|
|
477
|
+
|
|
478
|
+
The cost of the reversal is drift, so it is checked from two directions:
|
|
479
|
+
`migrate.py --check` diffs the live book against the file object by object, and
|
|
480
|
+
`test_schema.py` fails the suite when they disagree.
|
|
481
|
+
|
|
482
|
+
To change the schema: edit `schema.sql`, add a `schema_version` row saying what and why,
|
|
483
|
+
bump `SCHEMA_VERSION` in `migrate.py`, then `migrate.py --upgrade` (dry by default,
|
|
484
|
+
`--apply` to write). That does a **rebuild-and-copy** rather than the 12-step in-place
|
|
485
|
+
dance: it builds a fresh book from the new file, copies every table across, and refuses
|
|
486
|
+
to swap unless `integrity_check`, `foreign_key_check` and every per-table row count all
|
|
487
|
+
pass. The original is kept as `hub.pre-vN.db`.
|
|
488
|
+
|
|
489
|
+
> [!warning]
|
|
490
|
+
> The copy **drops the target's triggers first and recreates them after**. Without that,
|
|
491
|
+
> every `ev_*` audit trigger fires on each copied row and the new book opens with a
|
|
492
|
+
> fabricated history of a migration presented as user activity.
|
|
493
|
+
|
|
494
|
+
## Where hub.db lives, and where it must not
|
|
495
|
+
|
|
496
|
+
**Outside the repo — `~/Documents/trading-desk/hub.db` — and out of any file-sync
|
|
497
|
+
folder.** iCloud and Dropbox are workable but not free.
|
|
498
|
+
|
|
499
|
+
`journal_mode` is `delete`, never WAL, and that is deliberate: WAL keeps a `-wal`
|
|
500
|
+
sidecar, and the database is consistent only while it and the main file agree. Sync
|
|
501
|
+
services upload files independently and opportunistically — happily uploading `hub.db`
|
|
502
|
+
mid-transaction, or without its journal — and the result is corrupt or silently rolled
|
|
503
|
+
back, often undetected until much later. Two machines with the file open makes it
|
|
504
|
+
near-certain rather than merely possible. **One writer at a time.**
|
|
505
|
+
|
|
506
|
+
For an off-machine copy, snapshot instead of syncing:
|
|
507
|
+
|
|
508
|
+
```bash
|
|
509
|
+
sqlite3 ~/Documents/trading-desk/hub.db \
|
|
510
|
+
"VACUUM INTO '$HOME/Library/Mobile Documents/com~apple~CloudDocs/trading-desk-snapshots/hub-$(date +%F).db'"
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
`VACUUM INTO` writes a consistent point-in-time copy, which is safe to sync exactly
|
|
514
|
+
because it is not the live file.
|
|
515
|
+
|
|
516
|
+
> [!important]
|
|
517
|
+
> `PRAGMA foreign_keys` is **per-connection and defaults off**. Every client must set
|
|
518
|
+
> it on connect. The load-bearing references are *also* trigger-enforced, because a
|
|
519
|
+
> human at the `sqlite3` prompt will forget — verified on 3.51.0 that a foreign key
|
|
520
|
+
> alone accepts a mistyped bet id.
|
|
521
|
+
|
|
522
|
+
## Bet exposure
|
|
523
|
+
|
|
524
|
+
A bet's `weight` is **its open legs at market, over the derived net.** `spent` beside
|
|
525
|
+
`allocated` says how full the plan is, because the weight alone is not readable: a bet at
|
|
526
|
+
1.4% of the book on a declaration worth 24% of it is not a small position, it is an early
|
|
527
|
+
one.
|
|
528
|
+
|
|
529
|
+
**`filled` is entry cost over allocation, and every percentage that uses the word agrees.**
|
|
530
|
+
It was market value once, which gave the bets tab two answers — 60.1% on the detail line,
|
|
531
|
+
62.0% in the holdings header, three rows apart. Cost is the basis that makes the word mean
|
|
532
|
+
what it says and the only one on which `spent + dry == allocation`, which is the identity the
|
|
533
|
+
holdings bars are drawn from. What is at stake *now* is `value`, `weight` and the P&L
|
|
534
|
+
columns; each figure has one basis and the header names it.
|
|
535
|
+
|
|
536
|
+
**The denominator is not carved down to "risk capital", deliberately.** On a book that is
|
|
537
|
+
89% money-market, dividing by net-less-parking reads 13% where net reads 1.4% — more
|
|
538
|
+
flattering and arguably more informative, but it revives a risk-free classification that
|
|
539
|
+
was removed on purpose, and it makes the exposure figure depend on how each instrument
|
|
540
|
+
happened to be registered. One denominator, stated, with the parked total printed beside it
|
|
541
|
+
so the number can be read.
|
|
542
|
+
|
|
543
|
+
`v_bet_exposure` computed this in SQL until v12 and had four defects, the instructive one
|
|
544
|
+
being `COALESCE((SELECT amount FROM prices WHERE kind='fx' ...), 1.0)` — a USD leg with no
|
|
545
|
+
recorded rate valued at par, wrong by about 38%. **A figure that must be withheld when its
|
|
546
|
+
inputs are missing does not belong in a view**, because every SQL tool for absent values
|
|
547
|
+
substitutes a number instead.
|
|
548
|
+
|
|
549
|
+
## Status
|
|
550
|
+
|
|
551
|
+
Schema v12. The book is migrated and matches `schema.sql` exactly: 5 bets, 14 trades
|
|
552
|
+
(5 open, 4 of them attached to no bet), 30 fills, 14 falsifiers, 12 instruments, 6 flows.
|
|
553
|
+
|
|
554
|
+
**Known and open:**
|
|
555
|
+
|
|
556
|
+
- **A closed leg does not say which bet it was under.** Tab 2 groups closed legs by
|
|
557
|
+
currency, so the realised total is unambiguous, and six clipped characters of a bet id
|
|
558
|
+
would be worse than the blank. What each bet realised belongs on tab 4 as a column.
|
|
559
|
+
- **No MCP server.** `src/trades/` and `mcp_server.py` read the retired YAML ledger and
|
|
560
|
+
went with it.
|
|
561
|
+
- `income` and `fx_conversions` are **read but not writable** from the TUI. Both feed cash;
|
|
562
|
+
the `o` offset is the intended door for anything new.
|
|
563
|
+
- `desk/check.py`'s `attribution()` still computes realised P&L its own way, on average
|
|
564
|
+
cost, and no pane reads it. Tab 2 and the dashboard derive theirs from
|
|
565
|
+
`traded + positions` instead. **Two answers to one question**, and the one in `check.py`
|
|
566
|
+
is the one nothing depends on. `check.py` is also the last reader of
|
|
567
|
+
`account_snapshots`, which the desk no longer touches.
|
|
568
|
+
- **Three method names collided with Textual in one session** — `_closed`
|
|
569
|
+
(`MessagePump._closed`), a local `share` shadowing the imported one, and `_render`
|
|
570
|
+
(`Widget._render`). Two were swallowed by the message loop and left a pane rendering
|
|
571
|
+
nothing while the suite stayed green. `Pane.reload` now reports a failed draw in the
|
|
572
|
+
pane title and re-raises under test, but **the underlying hazard is unchanged**: a
|
|
573
|
+
method on a Textual widget shares a namespace with five base classes.
|
|
574
|
+
|
|
575
|
+
Deliberately unwritten, and not to be invented: a volatility threshold (`measurements`
|
|
576
|
+
records the numbers and enforces nothing), a time stop, a drawdown redline, an aggregate
|
|
577
|
+
ceiling across bets, and any verdict on any of them.
|