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.
Files changed (33) hide show
  1. trading_desk-0.3.0/LICENSE +21 -0
  2. trading_desk-0.3.0/PKG-INFO +577 -0
  3. trading_desk-0.3.0/README.md +552 -0
  4. trading_desk-0.3.0/desk/__init__.py +0 -0
  5. trading_desk-0.3.0/desk/__main__.py +176 -0
  6. trading_desk-0.3.0/desk/book.py +2308 -0
  7. trading_desk-0.3.0/desk/check.py +684 -0
  8. trading_desk-0.3.0/desk/config.py +225 -0
  9. trading_desk-0.3.0/desk/migrate.py +338 -0
  10. trading_desk-0.3.0/desk/parse.py +929 -0
  11. trading_desk-0.3.0/desk/prices.py +375 -0
  12. trading_desk-0.3.0/desk/schema.sql +734 -0
  13. trading_desk-0.3.0/desk/tui/__init__.py +0 -0
  14. trading_desk-0.3.0/desk/tui/app.py +757 -0
  15. trading_desk-0.3.0/desk/tui/app.tcss +109 -0
  16. trading_desk-0.3.0/desk/tui/charts.py +121 -0
  17. trading_desk-0.3.0/desk/tui/footer.py +124 -0
  18. trading_desk-0.3.0/desk/tui/keymap.py +234 -0
  19. trading_desk-0.3.0/desk/tui/panes.py +2166 -0
  20. trading_desk-0.3.0/desk/tui/prompt.py +197 -0
  21. trading_desk-0.3.0/desk/tui/themes.py +210 -0
  22. trading_desk-0.3.0/desk/tui/widgets.py +91 -0
  23. trading_desk-0.3.0/pyproject.toml +94 -0
  24. trading_desk-0.3.0/setup.cfg +4 -0
  25. trading_desk-0.3.0/tests/test_book.py +488 -0
  26. trading_desk-0.3.0/tests/test_desk.py +5268 -0
  27. trading_desk-0.3.0/tests/test_schema.py +323 -0
  28. trading_desk-0.3.0/trading_desk.egg-info/PKG-INFO +577 -0
  29. trading_desk-0.3.0/trading_desk.egg-info/SOURCES.txt +31 -0
  30. trading_desk-0.3.0/trading_desk.egg-info/dependency_links.txt +1 -0
  31. trading_desk-0.3.0/trading_desk.egg-info/entry_points.txt +4 -0
  32. trading_desk-0.3.0/trading_desk.egg-info/requires.txt +1 -0
  33. 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
+ ![the dashboard](assets/dashboard.png)
32
+
33
+ <details>
34
+ <summary>positions and bets</summary>
35
+
36
+ ![positions, grouped under their bet](assets/positions.png)
37
+
38
+ ![bets, with what each one is made of](assets/bets.png)
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.