Project

General

Profile

PF » History » Version 17

Redmine Admin, 11/04/2026 10:15 AM
deploy-publish.sh

1 5 Redmine Admin
*Chapter 8 of the dmz-web documentation · headings are numbered like the cheat sheets · index: [[Wiki]]*
2 1 Redmine Admin
3 5 Redmine Admin
{{>toc}}
4
5 17 Redmine Admin
**Version 1.20 — 2026-11-04** · companion of the host cheat sheet (conventions: [[Conventions]], [[NPM]], [[Operations]], [[Git_Workflow]])
6 5 Redmine Admin
7 1 Redmine Admin
Host `dmz-web` (Rocky Linux 10.2). Everything runs **rootless** under `websvcmaster` (no sudo) as Quadlet units. Design rule: **shared-nothing** — own networks, database, app container and volumes; the only shared piece is Nginx Proxy Manager (NPM), which joins the front network.
8
9
| Item | Value |
10
|---|---|
11
| Public name | `pf.sibernet.com.tr` (DNS A record → FortiGate WAN IP; Let's Encrypt HTTP-01) |
12 17 Redmine Admin
| App | `site-pf` — FastAPI/uvicorn on `:8000` (one worker), image `localhost/site-pf:<tag>` built locally from `~/apps/pf`; web GUI and API with own login (password + TOTP/QR, roles User and Admin), no public registration; app version 1.1.16 |
13 1 Redmine Admin
| Database | `db-pf` — TimescaleDB on PostgreSQL 16, `docker.io/timescale/timescaledb:2.31.0-pg16` |
14
| Networks | `net-pf` (NPM + app, outbound allowed — the app fetches market data) and `net-pf-db` (app + db only, `Internal=true`: the database has no route to the internet and NPM cannot reach it) |
15
| Volume | `db-pf-data` (PostgreSQL data) |
16
| Secrets | `pf-db-password`, `pf-app-secret` (both `openssl rand -hex 32`, created without a trailing newline) |
17
| Installer | `pf-install.sh` in `~/deploy` (this file's procedures are what it does) |
18
| Source | `pf-app-<ver>.tar.gz` (delivered with the release, not part of `dmz-web-deploy`); lives afterwards in its own repo |
19
20
Naming follows the host rule: containers `db-pf` / `site-pf`, secrets `pf-…`. The unit description `Description=Portfolio -- <domain>` is what `check-sites.sh` reads, so `./check-sites.sh | grep pf` works.
21
22
---
23
24 5 Redmine Admin
## 8.1 Install — `pf-install.sh` (websvcmaster)
25 1 Redmine Admin
26
First time, from `~/deploy` (shell setup: host cheat sheet §0), with the source tarball next to the script:
27 4 Redmine Admin
28 1 Redmine Admin
```bash
29 11 Redmine Admin
./pf-install.sh --app-src=pf-app-1.1.10.tar.gz --build --access-list=admin-segments
30 1 Redmine Admin
```
31
32
* `--app-src=FILE` unpacks the application (Containerfile, pinned `requirements.txt`, `app/`, `tests/`) into `~/apps/pf`; existing files are never overwritten, and an archive with absolute or `../` paths is refused. It is for the **first** install — later code comes from the app's own repo (§2).
33 11 Redmine Admin
* `--build` runs `podman build -t localhost/site-pf:1.1.10 ~/apps/pf` (the default tag; `--app-image=localhost/site-pf:<tag>` for another).
34 5 Redmine Admin
* `--access-list=NAME` puts that NPM Access List on the new Proxy Host. The app has its own GUI login (§8.5.1), so this is a second layer, not the only one — still recommended while the host is new.
35 1 Redmine Admin
* Other options: `--db-image`, `--app-dir`, `--timezone`, `--skip-npm-api`, `--force`; `./pf-install.sh -h`.
36
37
What it does, in order: generates the Quadlet files into `~/deploy/quadlet/` (`net-pf.network`, `net-pf-db.network`, `db-pf.volume`, `db-pf.container`, `site-pf.container`), creates the two secrets, adds `net-pf` to `nginx-proxy-manager.container`, installs the units, starts nets → db (waits until healthy) → app (waits for `/health`), **restarts NPM** (every site is down ~10–20 s — run it off-hours) and creates the Let's Encrypt certificate and Proxy Host through NPM's API.
38
39
Before it changes anything it compares the `Network=` lines of the **live** `nginx-proxy-manager.container` with the repo copy and stops if the live file knows a network the repo copy lacks (copying it over would cut that site off — the 2026-10-25 outage). If it stops: add the missing lines to the repo copy, commit, run again.
40
41
Prerequisite: the DNS name must already resolve to the FortiGate WAN IP, otherwise the certificate request fails (the rest still works; add cert and host in the NPM UI later: Forward Hostname `site-pf`, Forward Port `8000`, Force SSL, HTTP/2, Websockets, Block Common Exploits).
42
43
Then commit the generated units (root): `deploy-commit.sh -m "pf: portfolio stack on pf.sibernet.com.tr"`.
44
45 5 Redmine Admin
Open the site and create the administrator on the first-run page — §8.5.1. Verify:
46 1 Redmine Admin
47
```bash
48
curl -sI --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444 | head -1     # HTTP/2 200
49
curl -s  --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444/health        # {"status":"healthy"}
50
podman exec site-pf curl -s http://127.0.0.1:8000/health/db                                       # {"status":"db ok"}
51
./check-sites.sh | grep pf
52
podman network inspect net-pf net-pf-db | jq '.[].containers | keys'   # net-pf: site, nginx-proxy-manager · net-pf-db: db, site
53
```
54
55
---
56
57 5 Redmine Admin
## 8.2 Application code and images
58 2 Redmine Admin
59 1 Redmine Admin
The **no-host-packages rule** holds: no Python, pip or libraries on Rocky; everything lives in the image. The `Containerfile` is `python:3.12-slim-bookworm` + `curl` (for the healthcheck) + `pip install --only-binary=:all:` from a **fully pinned** `requirements.txt` (66 wheels) — no compiler in the image, and a dependency without a wheel fails the build loudly instead of compiling something unreviewed.
60
61
Source layout (`~/apps/pf`):
62
63
| File | Job |
64
|---|---|
65
| `app/config.py` | `PF_*` settings; DB URL built with `quote_plus`, so a password with `/ + = @ :` cannot break it |
66
| `app/database.py`, `app/models.py` | SQLAlchemy 2 (async, asyncpg); tables are created at start; `price_history` becomes a TimescaleDB hypertable when the extension is there |
67
| `app/auth.py` | bcrypt (SHA-256 pre-hash), TOTP with replay protection, JWT, per-IP rate limit, per-account lockout |
68 3 Redmine Admin
| `app/services/market_data.py` | Yahoo Finance (stocks, crypto, FX, dividends) and TEFAS (funds); raw, not dividend-adjusted prices |
69 5 Redmine Admin
| `app/services/drip.py` | dividend reinvestment (§8.5.3) |
70 1 Redmine Admin
| `app/services/portfolio.py`, `indicators.py` | positions, allocation, ideal basket and rebalancing hints (four classes), risk; RSI/MACD/SMA/EMA/Bollinger/ATR (pandas-ta) |
71 5 Redmine Admin
| `app/services/suggest.py` | market-suggested basket: benchmark trend rules, tilt capped by `PF_SUGGEST_MAX_TILT` (§8.5.6) |
72 3 Redmine Admin
| `app/services/mailer.py` | optional SMTP sender (notices, recovery links); silent when `PF_SMTP_HOST` is empty |
73 4 Redmine Admin
| `app/main.py`, `app/schemas.py`, `app/cli.py` | API, validation, admin commands |
74 1 Redmine Admin
| `app/static/` | the web GUI: `index.html`, `style.css`, `app.js` (no inline script or style, so the CSP stays strict; charts are drawn as SVG without libraries) and `fonts/` (Inter, SIL OFL, served from the app itself: the CSP allows `font-src 'self'` only, nothing is loaded from Google) |
75
| `tests/` | 54 tests (`pytest`), see "Tests" below |
76
77
* **Pinning:** `>=` ranges drift (on 2026-10-29 an unpinned install resolves to pandas 3.x and a beta of pandas-ta). The list was resolved for Python 3.12 and import-tested; it includes `greenlet`, which SQLAlchemy's async layer needs and which is easy to miss. Change versions on purpose, build a new tag, test, then switch the unit. Not used on purpose: `python-jose` and `passlib` (unmaintained; passlib breaks with current bcrypt) — the app uses `PyJWT` and `bcrypt` directly.
78
* **Image tags:** one tag per release (`site-pf:1.1.0`, `1.1.1`, …), never `latest` — the previous tag is the rollback.
79
* **Config (`PF_*`, set in `site-pf.container`):** `PF_BASE_CURRENCY` (default `TRY`; the default display currency of new users), `PF_CURRENCIES` (default `TRY,USD,EUR,GBP,QAR`: only the starting list; afterwards the administrator edits it in the browser under **Currencies**, and that list is kept in the database; rates for the pairs come from Yahoo, with the inverse, the dollar route and the official peg as fallbacks, so `QAR` works), `PF_DIVIDEND_WITHHOLDING` (fraction taken at source, default `0` — set it to what your broker really withholds, e.g. `0.10`), `PF_DRIP_DEFAULT` (default `true`), `PF_DIVIDEND_PAY_LAG_DAYS` (default `0`), `PF_SYNC_ENABLED` / `PF_SYNC_INTERVAL_HOURS` (daily), `PF_REQUIRE_2FA` (default `true`: end users must enrol 2FA, administrators may; `false`: optional for everybody — an administrator can still require it per account), `PF_SUGGEST_MAX_TILT` (default `10`, hard cap `20`: the most percentage points the market-suggested basket may move one asset class away from the user's own basket; a server setting on purpose, not a per-user one), `PF_PW_LOCK_HOURS` (24), `PF_RECOVERY_TOKEN_MINUTES` (30), `PF_SMTP_HOST` / `PORT` (587) / `USER` / `PASSWORD` / `FROM` / `STARTTLS`, `PF_PUBLIC_URL` (links in e-mails; default `https://<PF_DOMAIN>`), `PF_LOGIN_RATE_LIMIT` (default `5/minute`), `PF_ACCESS_TOKEN_MINUTES` (30), `PF_ENABLE_DOCS` (`0`: hides `/docs`, `/redoc`, `/openapi.json`; `1` only while developing, never behind the public Proxy Host). The app refuses to start when `PF_SECRET_KEY` (secret `pf-app-secret`) is shorter than 32 characters.
80 5 Redmine Admin
* **Source code is not in `dmz-web-deploy`.** Keep `~/apps/pf` in its own repository (e.g. a bare repo `/srv/git/pf-app.git`, same procedure as host cheat sheet §3.1.2 to §3.1.6) and tag it with the same number as the image.
81
* **Tables:** `create_all` at start creates what is missing. Later columns are added by a short list of idempotent `ALTER TABLE … ADD COLUMN IF NOT EXISTS` statements in `app/database.py` that runs at every start (release 1.1.1 adds seven columns to `users`: `must_change_password`, `require_2fa`, `display_currency`, `is_owner`, `email`, `self_service_recovery`, `pw_locked_until`, and makes the oldest administrator the Owner). **Release 1.1.2 adds** `users.ideal_basket`; `transactions.rate_monthly`, `withholding_pct`, `term_months`, `maturity_date`, `parent_id` (deposits and interest); the enum values `asset_type = deposit` and `etf` and `tx_source = interest` (`ALTER TYPE … ADD VALUE IF NOT EXISTS`); the share columns `transactions.quantity`, `dividends.shares` and `dividends.reinvest_quantity` are widened to `NUMERIC(27,9)`; and the new table `app_settings` (the administrator's currency list). **Release 1.1.3 changes no schema. Release 1.1.4 adds** the table `asset_splits` (created by `create_all`: one row per split or bonus issue of an instrument) and the column `users.ideal_basket_base` (`ADD COLUMN IF NOT EXISTS`; set only while a user runs the market-suggested basket). Nothing is dropped or rewritten; **take a `pg_dump` (§8.3.3) before the first start of a new version anyway.**
82 1 Redmine Admin
* **Tests:** they need a throw-away PostgreSQL, never the production database (`PF_ENV=test PF_TIMESCALE=off PF_SECRET_KEY=<32+ chars> PF_DATABASE_URL_OVERRIDE=postgresql+asyncpg://user:pw@host/pf_test pytest`, with `requirements-dev.txt` installed in a venv away from the host's Python — e.g. on a workstation). Market data is faked in the tests: they prove the logic, not Yahoo/TEFAS behaviour.
83
84
---
85
86 5 Redmine Admin
## 8.3 Operations
87 1 Redmine Admin
88 5 Redmine Admin
### 8.3.1 New application version
89 1 Redmine Admin
90 13 Redmine Admin
*Short way:* a release that carries an application version upgrades it at the end of `deploy-publish.sh` (the plan shows the dry run of `pf-upgrade.sh`; one question for everything). On its own, as root, `pf-upgrade.sh <version>` does the steps below, asks before each one and keeps you in the root shell (§5.7). Since 1.1 it takes the tarball's sha256 from `releases/CHECKSUMS.txt` in the repo (or `-s <sha256>`), names the known TimescaleDB `pg_dump` notice as harmless, and moves superseded tarballs from `/root` to `/root/deploy-done/<stamp>-pf-app/` (the replaced version stays for a rollback). The manual steps stay valid.
91 6 Redmine Admin
92 1 Redmine Admin
The source is delivered as `pf-app-<ver>.tar.gz`. The old source stays aside as the rollback:
93
94 2 Redmine Admin
```bash
95 5 Redmine Admin
# 0. backup first: §8.3.3 (pg_dump -Fc), copy it off the host
96 11 Redmine Admin
# (root) the tarball goes to ~websvcmaster/deploy first:  cp pf-app-1.1.10.tar.gz /home/websvcmaster/deploy/ && chown websvcmaster: /home/websvcmaster/deploy/pf-app-1.1.10.tar.gz
97 7 Redmine Admin
cd ~ && mv apps/pf apps/pf-1.1.5                        # keep the old tree
98 11 Redmine Admin
mkdir apps/pf && tar -xzf ~/deploy/pf-app-1.1.10.tar.gz -C apps/pf --no-same-owner --no-same-permissions
99
podman build -t localhost/site-pf:1.1.10 ~/apps/pf
100 4 Redmine Admin
# repo copy (websvcmaster): change the Image= line, keep HealthStartPeriod=90s
101 11 Redmine Admin
sed -i 's#^Image=localhost/site-pf:.*#Image=localhost/site-pf:1.1.10#' ~/deploy/quadlet/site-pf.container
102
# root (not websvcmaster: the script is root-only):  deploy-commit.sh -m "pf: app 1.1.10"
103 1 Redmine Admin
cp ~/deploy/quadlet/site-pf.container ~/.config/containers/systemd/
104
systemctl --user daemon-reload && systemctl --user restart site-pf.service     # only site-pf, not db-pf and not NPM
105
for i in $(seq 1 12); do s=$(podman inspect site-pf --format '{{.State.Health.Status}}'); echo "$i: $s"; [ "$s" = healthy ] && break; sleep 10; done
106
podman logs site-pf | tail -20
107
~/deploy/check-sites.sh | grep pf
108
```
109
110 3 Redmine Admin
Check the result (websvcmaster):
111 1 Redmine Admin
112 2 Redmine Admin
```bash
113 3 Redmine Admin
~/deploy/check-sites.sh | grep pf                                    # HTTP 200 / healthy
114
podman exec db-pf psql -U pf_user -d pf_db -c "\d users" | grep ideal_basket
115 4 Redmine Admin
podman exec db-pf psql -U pf_user -d pf_db -c "\d transactions" | grep -E "rate_monthly|withholding_pct|term_months|maturity_date|parent_id"
116
podman exec db-pf psql -U pf_user -d pf_db -c "select enum_range(null::asset_type), enum_range(null::tx_source)"   # deposit, etf and interest are in the lists
117
podman exec db-pf psql -U pf_user -d pf_db -c "\d transactions" | grep quantity                                   # numeric(27,9)
118 3 Redmine Admin
podman exec db-pf psql -U pf_user -d pf_db -c "\dt" | grep app_settings
119
podman exec db-pf psql -U pf_user -d pf_db -c "select hypertable_name from timescaledb_information.hypertables"    # price_history
120
```
121
122
In the browser press **Ctrl+F5** once (new `app.js`, `style.css` and the Inter font files). The first dividend sync runs about 60 seconds after the start; fractional shares are now kept to **9 decimals** (as Midas; the three share columns are widened by an idempotent migration), so existing dividend purchases (rounded to 6) change only after *apply to past* on a holding (Dividends page).
123 4 Redmine Admin
124 17 Redmine Admin
Rollback: put the previous tag back in the unit (same four lines). From 1.1.16 back to 1.1.15: the column `transactions.account` is ignored by the old code, **but a fee or tax taken from a Midas cash account (source `cost`) cannot be read by it**: after an import with such lines, roll back by restoring the dump of the upgrade, not by the tag alone. From 1.1.15 back to 1.1.14: the new columns are ignored by the old code, **but once Midas statements were imported the old code cannot read their rows** (the source value `broker` is unknown to it): after an import, roll back by restoring the dump of the upgrade, not by the tag alone. From 1.1.14 back to 1.1.13 is always safe (no schema change; deposit renewals count as new money in again and the risk / analysis error with funds priced 0 comes back). From 1.1.13 back to 1.1.12: the table `ui_labels` and the columns `users.language`, `transactions.paid_currency`, `transactions.paid_amount` are ignored by the old code (it shows English and costs at today's rate again), **but once a bonus was entered the old code cannot read that row** (the source value `bonus` is unknown to it): after bonuses, roll back by restoring the dump of the upgrade, not by the tag alone. An instrument whose currency was corrected stays correct for the old code. From 1.1.12 back to 1.1.11 is safe: the new table `auth_refresh` and the column `users.sessions_valid_after` are ignored by the old code; everybody logs in again once and the old version's in-memory login comes back. From 1.1.11 back to 1.1.10 is always safe (no schema change). From 1.1.10 back to 1.1.9 is always safe (no schema change). From 1.1.9 back to 1.1.8 is always safe (no schema change). From 1.1.8 back to 1.1.7: the two new columns of `transactions` (`period_from`, `avg_balance`) are harmless to the old code; interest lines made from statements stay and show as ordinary interest. Without the statement import and the price-currency choice, nothing else is lost. From 1.1.7 back to 1.1.6: the three new columns of `transactions` (`rate_annual`, `term_days`, `at_maturity`) are harmless to the old code, **but automatic deposit rows (note `auto: ...`) that were already booked stay** and the old version will not create any more; if that matters, restore the dump from step 0. From 1.1.6 back to 1.1.4 is always safe (no schema change since 1.1.4). From 1.1.4 back to 1.1.3 the added table and column are harmless to the old code, **but once a split is stored the old version no longer counts old trades in the new share unit and its dividends no longer match**: after splits were synced, roll back by restoring the dump from step 0, not by changing the tag alone. From 1.1.3 back to 1.1.2 is always safe (no schema change); the caveat below concerns going back past 1.1.2. The added columns are harmless to the old version, **but only as long as no deposit or interest line exists**: the old code does not know the enum values `deposit` / `interest` and fails on those rows. After deposits were entered, roll back by restoring the dump from step 0, not by changing the tag alone.
125 1 Redmine Admin
126 5 Redmine Admin
### 8.3.2 Logs and shell
127 1 Redmine Admin
128
```bash
129
journalctl --user -u site-pf.service -f
130
podman logs -f site-pf          podman logs -f db-pf
131
podman exec -it db-pf psql -U pf_user -d pf_db
132
```
133
134 5 Redmine Admin
### 8.3.3 Backup and restore — TimescaleDB is not plain PostgreSQL
135 1 Redmine Admin
136
A plain dump restored the plain way breaks hypertables. Use the custom format and restore with the Timescale pre/post calls:
137
138
```bash
139
# backup (websvcmaster) — then copy the file OFF the host; a dump next to the database is not a backup
140
podman exec db-pf pg_dump -U pf_user -Fc pf_db > ~/tmp/pf_db-$(date +%F).dump
141
142
# restore into an EMPTY database (same TimescaleDB version or newer)
143
podman exec db-pf psql -U pf_user -d postgres -c "create database pf_restore"
144
podman exec db-pf psql -U pf_user -d pf_restore -c "create extension if not exists timescaledb"
145
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_pre_restore()"
146
podman exec -i db-pf pg_restore -U pf_user -d pf_restore --no-owner < ~/tmp/pf_db-2026-10-29.dump
147
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_post_restore()"
148
```
149
150 13 Redmine Admin
`pg_dump` always prints a warning about circular foreign keys on `continuous_agg` (TimescaleDB's own catalog). It concerns `--data-only` dumps; this full dump restores with the pre/post calls above. To replace `pf_db` itself (a rollback that needs the old data): stop `site-pf.service`, `drop database pf_db` / `create database pf_db` (from database `postgres`), then the same four lines with `-d pf_db`, start `site-pf.service`. `pf-upgrade.sh` prints these lines with the right file name at its end.
151
152 1 Redmine Admin
**Do one restore test before trusting the backups**, and again after each TimescaleDB upgrade. The application volume has nothing to back up (no uploads); the secrets are not needed for a restore (set a new password in the new environment).
153
154 5 Redmine Admin
### 8.3.4 Password rotation
155 1 Redmine Admin
156
`POSTGRES_PASSWORD` is read only when the data directory is first created. To rotate: `podman exec db-pf psql -U pf_user -d pf_db -c "alter user pf_user password '<new>'"`, then `podman secret rm pf-db-password`, create it again with the new value (`printf '%s' '<new>' | podman secret create pf-db-password -`), restart `db-pf.service` and `site-pf.service`.
157
158 5 Redmine Admin
### 8.3.5 Upgrading the database image
159 1 Redmine Admin
160
Dump first (C). Patch/minor (`2.31.0-pg16` → `2.31.1-pg16`): change the tag in the unit, `cp`, `daemon-reload`, restart `db-pf.service`, then `ALTER EXTENSION timescaledb UPDATE;` in `pf_db` (the first command in a fresh session). A PostgreSQL major jump (pg16 → pg17) is a dump-and-restore into a new volume, not a tag change.
161
162
---
163
164 5 Redmine Admin
## 8.4 Troubleshooting
165 1 Redmine Admin
166
* **App restarts in a loop right after boot** → it came up before the database accepted connections. The units wait for `db-pf` (Podman 5: `Notify=healthy`); check `podman healthcheck run db-pf` and `podman logs db-pf`.
167 5 Redmine Admin
* **`/health` is 200 but `/health/db` is 503** → the password in the secret is not the password the database was initialised with (secret recreated after first start): rotate as in §8.3.4, or drop the volume if the database is still empty.
168 1 Redmine Admin
* **NPM 502 for the pf host** → NPM is not on `net-pf` (`podman network inspect net-pf | jq '.[0].containers | keys'`); check the `Network=net-pf.network` line in the live `nginx-proxy-manager.container` and that NPM was restarted afterwards. Other sites also 502 → host cheat sheet §7 "NPM says 502".
169
* **Certificate request fails** → the DNS name does not point to the FortiGate WAN IP yet, or TCP 80 does not reach NPM; fix DNS, then request the certificate in the NPM UI.
170 3 Redmine Admin
* **App container exits at once, log says `PF_SECRET_KEY ... shorter than 32 characters`** → the `pf-app-secret` secret is missing or empty; recreate it (`openssl rand -hex 32 | tr -d '\n' | podman secret create pf-app-secret -` after `podman secret rm pf-app-secret`) and restart `site-pf.service`. Existing login tokens become invalid (30 minutes anyway).
171 1 Redmine Admin
* **The first-run page does not appear** → an administrator already exists (`podman exec -it site-pf python -m app.cli list-users`). To get the first-run page again on a system that has a user created by the CLI: `python -m app.cli set-role NAME user` for every administrator — the page appears when **no** administrator exists; the setup code is in `podman logs site-pf | grep FIRST-RUN` (printed at start while no administrator exists, and again when the first-run page is opened without one — grep again after opening the page; valid until used or until the next start).
172
* **The chart for 1D / 3D shows few points** → prices are daily closes; there is no intraday data. 1W and longer are the meaningful periods.
173 2 Redmine Admin
* **Login answers 429** → either the per-IP limit (5 attempts per minute, the answer comes back after a minute) or the account lock (5 wrong passwords or codes lock the account for 15 minutes): `podman exec -it site-pf python -m app.cli unlock NAME`.
174
* **Rate limit hits everybody at once** → every request seems to come from one address (the proxy). Check that NPM passes `X-Forwarded-For` (it does by default) and that the app runs with `--proxy-headers` (it does in the `Containerfile`).
175 1 Redmine Admin
* **Authenticator code is always refused** → phone clock drift (turn automatic time on), or the code was already used (each 30-second code works once). Lost phone: an administrator uses *Reset 2FA* (or *Set temporary password* with "also remove the old 2FA") in the GUI, or on the host `python -m app.cli reset-2fa NAME`; then the person enrols again.
176 3 Redmine Admin
* **A dividend is stuck in `pending_price`** → no price for its pay date yet (pay date in the future, or the data source was down); the daily sync completes it. Force it: `POST /api/v1/dividends/sync`.
177 5 Redmine Admin
* **`pg_dump` prints a warning about circular foreign-key constraints** → a notice, not an error: the dump still completes. Prove it with the restore test in §8.3.3 (`pg_restore -l` lists the tables, the row counts match).
178 3 Redmine Admin
* **`podman build` prints "Running pip as the 'root' user …"** → harmless; it happens inside the image build, not on the host.
179
* **A hand-entered dividend appears next to the one from the market feed** → the feed row is keyed by ex-date, a hand-entered one under another date (typically the pay date) is a second row. The Dividends page compares them (chart "Dividends you entered vs. market data" and a *differs* tag); *Use market data* replaces the hand-entered row, *Delete* removes it together with its purchase. Feed rows cannot be deleted — the next sync would add them back; switch reinvestment off for the holding instead.
180 1 Redmine Admin
* **"Market-suggested basket" says "not available right now"** → Yahoo could not be reached from the app container (or the benchmark has too little history); `podman logs site-pf | grep benchmark`. The rest of the page is unaffected.
181
* **Build fails on a dependency** → no wheel for Python 3.12 (the `--only-binary` guard); pick a version with a wheel, do not install a compiler into the image.
182
183
---
184
185 5 Redmine Admin
## 8.5 Using the application
186 2 Redmine Admin
187 5 Redmine Admin
### 8.5.1 Web GUI, roles and first run
188 2 Redmine Admin
189 1 Redmine Admin
Open `https://pf.sibernet.com.tr`. Everything the API can do is in the GUI (the API stays for a later mobile app).
190 2 Redmine Admin
191
**First run.** While no administrator exists the page asks for the administrator password. It also needs the **setup code** that the app prints to its log at start (so only someone with host access can claim the system):
192 3 Redmine Admin
193 1 Redmine Admin
```bash
194
podman logs site-pf | grep FIRST-RUN        # printed at start while no administrator exists, and when the first-run page is opened without one; valid until used or the next start
195
```
196 2 Redmine Admin
197 1 Redmine Admin
The administrator created there is the **Owner** (user name `admin` unless typed otherwise).
198
199 2 Redmine Admin
**Roles.**
200
201
| | User | Administrator | Owner |
202 1 Redmine Admin
|---|---|---|---|
203 2 Redmine Admin
| Portfolio, trades, dividends, charts, analysis | yes | **no** (403 on every data endpoint) | no |
204
| Create / deactivate accounts, set temporary passwords, reset 2FA, require 2FA, set e-mail / recovery, unlock | no | yes, for users | yes, also for administrators |
205
| Create other administrators | no | no | yes |
206 1 Redmine Admin
| 2FA | **mandatory** (`PF_REQUIRE_2FA=true`); QR code at the first login | optional (can be added later in *My account*; an enforcement per account is possible) | like an administrator |
207
208
Several users and several administrators are possible. The Owner cannot be deactivated. New accounts get a **temporary password** (the GUI can generate one and shows it once); the person must choose their own at the first login. Password rules: at least 12 characters, upper and lower case, a digit.
209
210 16 Redmine Admin
**Language (1.1.13).** Every text of the screens comes from `strings.csv` (in the app: one row per key, one column per language, header `key,en,tr`). *Settings* (*My account* for administrators) → *Language*: *As the browser* or a language; the page reloads in it and numbers follow it (`1.234,56` in Turkish). An empty cell shows the English text. Since 1.1.15 the server writes its messages in the page's language too (the page sends header `X-PF-Lang`): errors, warnings, the analysis reasons and disclaimers; e-mails follow the language chosen under *Language* (English when it is *As the browser*). They are the rows `srv.*` of the same file, so the *Labels* page changes them as well; a message without a row stays English.
211 14 Redmine Admin
212
**Labels (administrators, 1.1.13).** *Labels* page: **download** the CSV (comma, or semicolon for Excel with Turkish settings; UTF-8 with BOM), change any text in Excel or LibreOffice, **check** the file, **upload and apply** — at once, without a release; *Back to the built-in texts* undoes it. Only the cells that differ from the built-in file are kept (table `ui_labels`), so a later version still brings its own new and corrected texts and your changes stay on top. A new column with a language code (`de`, `fr`, `ar` …) plus its name in the row `lang.name` adds a language. A file with a `{placeholder}` missing or unknown, a key twice, or not in UTF-8 is refused whole and nothing changes. The texts reach the page as `/i18n.js` (public: the login page needs them; nothing in it is secret). Right-to-left languages would also need layout work.
213
214 13 Redmine Admin
**Staying logged in (1.1.12).** A full login (password and, where needed, the 2FA code) sets an **HttpOnly cookie** `pf_rt` (Secure, SameSite=Strict, path `/api/v1/auth`) that the page itself cannot read. The page keeps only a short **access token** (15 min, `PF_ACCESS_TOKEN_MINUTES`) in memory and renews it from the cookie: at load (so **F5, Back and a new tab stay logged in**) and when a request comes back 401. The cookie is replaced at every use (rotation); only its sha256 is stored (table `auth_refresh`). A login ends after **12 hours unused** (`PF_REFRESH_IDLE_HOURS`) or **7 days** after the password was typed (`PF_REFRESH_MAX_DAYS`). If an already replaced cookie comes back later than 30 s (a copied cookie), that whole login is ended. **Every login of the account ends** — refresh cookies revoked and access tokens refused at once (`users.sessions_valid_after`) — on *Log out*, a password change or reset, a 2FA change and deactivation; the browser that changed its own password or 2FA goes on with a new cookie. Logging in with a temporary password or for 2FA enrolment does not set the cookie.
215
216 1 Redmine Admin
**2FA login.** At the first login a user who needs 2FA gets a restricted session that can only enrol: the page shows a QR code (and the secret for manual entry); after the 6-digit code is confirmed the session continues. Later logins ask for the code (each code works once; ±30 s clock tolerance).
217
218
**Lost password / lost authenticator.**
219
220 2 Redmine Admin
| Case | What happens |
221
|---|---|
222
| Lost password | Administrator: *Users → Reset a password* (temporary password, forced change at the next login). |
223
| Lost authenticator (password known) | Administrator: *Reset 2FA*; the person enrols again at the next login. If the account has an e-mail address, a notice is sent and the **password cannot be changed by the person for 24 hours** (`PF_PW_LOCK_HOURS`). |
224
| Lost both | Administrator: *Reset a password* with "also remove the old 2FA" — one step; the forced password change at the next login stays allowed, enrolment is repeated. |
225 1 Redmine Admin
| Self-service | Only if an administrator gave the account an **e-mail address** and ticked **allow e-mail recovery** (*Users → E-mail / recovery…*), **and** SMTP is configured. Login page: *Forgot password* / *Lost authenticator* → an e-mail with a single-use link (30 min; it dies with any later password or 2FA change; the link travels in the URL fragment, not in server logs). *Lost authenticator* also asks for the current password, then removes 2FA, sends a notice and starts the 24 h password lock; *Forgot password* is refused while that lock is on. The reply to a request is the same for every account, so nobody can probe which users exist. |
226 9 Redmine Admin
| Nothing configured | Without `PF_SMTP_HOST` no mail is sent and the administrator path above is the only one. The login page always shows *Forgot password* and *Lost authenticator*; without a mail server the page tells the person to ask the administrator. |
227 2 Redmine Admin
228 1 Redmine Admin
On the host (break-glass, no GUI): `podman exec -it site-pf python -m app.cli list-users | set-password NAME | unlock NAME | reset-2fa NAME | set-role NAME user|admin | create-user NAME [--admin]`. The password is typed at a hidden prompt, never given as an argument.
229 2 Redmine Admin
230
**Data isolation.** Every query filters on the logged-in user's id; another user's transactions, dividends and settings answer 404, not 403. Instruments (`/assets`) are a shared catalogue; ownership exists only through transactions.
231 1 Redmine Admin
232 5 Redmine Admin
### 8.5.2 Pages (user)
233 4 Redmine Admin
234 16 Redmine Admin
* **Portfolio** — *Total wealth* (large figure, decimals small) in the display currency chosen at the top right of the card (TRY, USD, EUR, GBP, or any code with a Yahoo rate; saved per user), the change over the chosen period as a green or red pill, and a chart: **1D, 3D, 1W, 1M, 3M, 6M, YTD, 1Y, All**. The line is **green** when the portfolio gained over the period and **red** when it lost; "gain" excludes money you paid in or took out during the period (a deposit of money is not a win). The soft fill, the dashed level and the dot at the end are drawn the same way on every chart. Prices are daily closes, so 1D and 3D have few points. Below: totals (with *Interest received* once a deposit has interest), positions, **Holdings: market analysis and suggestion**, **Ideal basket vs. your portfolio**, weight by holding, risk. Every symbol in these lists opens that holding's own page. The Positions and Holdings analysis tables open **grouped, with a separator row per group — US Stocks, ETFs, BIST (symbols ending `.IS`), Turkish Funds, Interest, Crypto** — and A to Z inside each group; a click on any other column header sorts one flat list by it (the separators disappear), a second click reverses, and a click on *Asset* brings the groups back. **By group (1.1.15)** next to the period buttons switches the chart to one line per group: its gain since the start of the period. The **percentage** next to the period gain is the gain over the money at work — the value at the start plus what was paid in during the period (1.1.15; the weighted-capital figure before gave +70 % over three years when most money came late). **Gain by group (1.1.14)** sits under the chart and follows the same period buttons: one bar per group (US Stocks, ETFs, BIST, Turkish Funds, Interest, Crypto) from a middle line — gains to the right, losses to the left — with the group's **share of today's wealth** next to its name. The bar is what the **prices** earned, without the currency; where the currency moved it, a **dot** on the bar and the **●** figure under the amount give the group's gain **with** the currency. The currency's part is in the last bars, **one per currency** the instruments are quoted in (*Currency effect TRY*, later *EUR* …; none for instruments already in the display currency), so the bars add up to the period's gain above (same measure: change in value minus money paid in or taken out). A click on a group lists its instruments with their share and gain. The total wealth chart, the holding chart and the Market chart show the **date and value under the pointer** (mouse hover, or a tap or click on a touch screen).
235
* **Holding** (menu item, or click any symbol) — one holding on one page: first the **My position** card — quantity, total value, average cost, share of the portfolio, *Today* (move of the last session × your quantity) and *Total return* (amount and %) — then the details (last price with the **day it is of** — the last days' prices are fetched again at most every 15 minutes, 1.1.15 —, unrealized and realized P/L, dividends, DRIP units; *unrealized* = today's value of what you still hold minus its cost, *realized* = profit locked in by selling). Amounts on this page are in the **asset's own currency** (TRY for a BIST stock), not the display currency; a price chart with the same period buttons and green/red line, with **markers** for your operations (buy ▲, sell ▼, dividend ◆, interest ●; hover for date, quantity, price) and, under it, **every buy, sell, dividend, reinvestment and interest line** of that asset with the quantity held after each. A deposit shows its balance instead of a price. **Edit** (own buys and sells only) turns the row into a small form: instrument, buy/sell, date, quantity, price, fee — so a wrongly typed ticker is corrected in place; both instruments are re-checked and their dividends recomputed. A *Splits and bonus issues* box appears when the instrument had one.
236 5 Redmine Admin
* **Holdings analysis** — one row per holding with weight, unrealized P/L, RSI, trend and a suggestion — **BUY, ACCUMULATE, HOLD, REDUCE, SELL** — and the reasons. It is a score from six rules (RSI over 70 / under 30, MACD against its signal line, price against the 50-day average, 50-day against 200-day average, Bollinger bands): +3 or more BUY, +2 ACCUMULATE, −2 REDUCE, −3 or less SELL, otherwise HOLD. Technical signals only — they know nothing about fundamentals, taxes, your goals or your other assets. Deposits are not analysed. Information, not investment advice.
237 3 Redmine Admin
* **Ideal basket vs. your portfolio** — four boxes at the top say what to **add or remove** per class (Deposits, Funds, Stocks, Crypto) to reach your ideal basket; below, the ideal and the real mix as bars and a table. A class within 5 points counts as on target. The **Market-suggested basket** box (§8.5.6) sits inside this card.
238 14 Redmine Admin
* **Trades** — (instrument list: choose an existing instrument and a type selector and *Remove this instrument* appear; both work only while no trade or dividend uses the instrument, e.g. after all its trades were deleted) new trade; for a **deposit** the form asks for the amount, the **yearly rate and the term in days** (or monthly, or no term), the **tax withheld** and **what happens at maturity** (§8.5.4); *Deposits and maturities* (the choice can be changed there); *Update balance*; *Interest received*; one history of trades, dividends (with per-share), dividend reinvestments and interest, grouped **by year with every year closed** until you open it (filters for instrument, type, year and a date range open the matching years; *Rows per year*: 50 / 100 / 200 / all, then *Show more*), each line with a *Delete* where it is allowed. The same *Delete* is on every holding's own page, which is the easiest way to remove an entry made under the wrong instrument. **One currency on the form (1.1.13):** *Currency* next to Price is the currency of this trade (what you paid in: a coin bought in TRY on one broker and in USD on another). It starts as the currency the price source quotes the instrument in (US stocks and coins USD, Borsa Istanbul and TEFAS TRY). The instrument's own currency is no longer chosen: PF takes it from the source (`BTC-USD` is USD whatever the form says); only a **deposit** asks for its account currency. *Get market price* gives the close in the chosen currency (converted at that day's rate when needed); changing the currency or the date fetches a market price again, and numbers typed by hand (price, total, fee) are converted at that day's rate, so Price and Total always read in the currency next to them. The same conversion is on the *Edit* row of a holding page. Prices and fees keep 8 decimals (a longer converted figure is rounded, not refused). **Bonus (received free)** next to *Side* (only with Buy / Pay in): a broker's gift or a bank's campaign — no fee, the price is the day's close, it counts as income (*Gifts and bonuses*) and not as money paid in (§8.5.8). Next to the instrument list a **currency selector** corrects an instrument saved in the wrong currency (§8.5.8). The form takes any two of **Quantity, Price and Total (before fee)**; a broker shows the price rounded, so enter the share count and the amount and leave Price empty. The fee is separate and counts as part of what you paid; the form shows the **total with the fee** (a buy: total + fee, a sell: total − fee) next to the button. The Instrument box starts empty ("Choose an instrument…") so a trade cannot go to the wrong instrument by default. A price can be typed in **another currency than the instrument's** (*Price currency* next to the price: a coin kept in USD that you bought in TRY): PF converts price and fee at that day's market rate and the note keeps what was paid and the rate used (`paid_currency` on `POST /transactions` and `PUT /transactions/{id}`; for a row typed in the wrong currency use *Edit* with the right one). For type *crypto* a bare symbol is tried as `<SYMBOL>-USD` first (on Yahoo a bare `BTC` is a fund of about 38 dollars, the coin is `BTC-USD`). The Positions table has a **Currency** column: *Avg cost* and *Price* are in the instrument's own currency, *Value* and *Total return* in the display currency.
239 9 Redmine Admin
* **Dividends** — reinvestment switch per holding, sync, chart, **Record a dividend by hand**, the chart **Dividends you entered vs. market data**, history. A hand-entered dividend is compared with the market feed (*matches market data* / *differs* with the reason / *cannot verify*); *Use market data* replaces it, *Delete* removes it with its purchase.
240 16 Redmine Admin
* **Market** (the *study* of one instrument: price with the 50- and 200-day averages and Bollinger bands, **RSI** and **MACD** on one time axis, ranges 3M to All; move the pointer or tap and the box below reads out **that day** — indicator values, the rules that fired with their points, the score and suggestion the rules gave **on that day** (only data up to that day is used) and what the price did since — with your own buys and sells on the price line; *How to read these panels* explains each indicator; below it the table of today's values), **Settings** (*Dividends*: reinvest by default and the **tax withheld by market** — Borsa Istanbul and other markets, in percent; used for dividends taken from the market feed until a broker statement replaces them, empty = the server's default, 1.1.15; *Ideal basket*: radio buttons **Conservative / Balanced / Growth / My own** — a preset locks the four fields, *My own* unlocks them and needs a total of exactly 100 %; 2FA; password).
241 11 Redmine Admin
* **About** — the small version label next to *PF Portfolio* in the top bar (for example `v1.1.10`); click it for the version, how long the app has been running, database state and the date of the latest stored price. Handy for screenshots when something needs support. `GET /api/v1/about` (login needed).
242 1 Redmine Admin
* Administrators see **Users** and **My account** only.
243
244 5 Redmine Admin
### 8.5.3 Dividend reinvestment (DRIP)
245 1 Redmine Admin
246
| Rule | Behaviour |
247
|---|---|
248
| Switch | per holding (`PUT /api/v1/holdings/{asset_id}/reinvest`), else the user default (`PUT /api/v1/auth/profile` `drip_default`), else `PF_DRIP_DEFAULT` (on) |
249 3 Redmine Admin
| Entitlement | shares held at the close **before** the ex-date; a purchase on the ex-date earns nothing |
250 1 Redmine Admin
| Amount | `shares × per-share × (1 − withholding)` |
251
| Reinvest on | a BUY (`source: drip`, fee 0) is booked at the first close on/after the pay date; quantity = net ÷ price, rounded **down** to 9 decimals (as brokers such as Midas keep them); the rest is kept as `cash_residual` |
252
| Reinvest off | the net amount is recorded as cash income (`status: paid`) |
253 3 Redmine Admin
| No price yet | `status: pending_price`; the daily sync completes it (the later dividends of that asset wait, so the share count stays right) |
254 1 Redmine Admin
| Compounding | reinvested shares are normal shares and earn the next dividend |
255
| Idempotent | one row per user, asset and ex-date — syncing twice does nothing |
256
| Manual entry | `POST /api/v1/dividends` for companies the feed does not know; optional `withholding`, `reinvest`, `reinvest_price`. An entry within 30 days of another dividend of the same holding is refused with 409 ("looks like the same payout") unless `?force=true` (the GUI asks). `DELETE /api/v1/dividends/{id}` removes a hand-entered row with its purchase and recomputes the later ones (feed rows: 409). `GET /api/v1/dividends/verify` compares hand-entered rows with the feed (amount within 2 % or equal after rounding to 2–4 decimals; ex-date within 3 days → `ok`; same amount but another date → `date_differs`; otherwise `amount_differs` / `no_data`); `POST /api/v1/dividends/{id}/use-market` replaces the row with the feed's version |
257
| Corrections | adding or deleting a manual trade recomputes that asset's dividends; `PUT .../reinvest?apply_to_history=true` and `POST /api/v1/dividends/rebuild/{asset_id}` do it on demand. DRIP purchases cannot be deleted by hand (409) |
258
| Returns | reinvested dividends raise the cost basis but are **not** new money: `money_invested` counts only your own purchases, `total_return = market value + sale proceeds + cash dividends − money invested` |
259
260
Limits to know: Yahoo reports ex-dates only, so the pay date is `ex-date + PF_DIVIDEND_PAY_LAG_DAYS` (0) — for a company that pays about three weeks after the ex-date (NVIDIA) the reinvestment price is therefore the one around the ex-date, a small difference; the dividend is assumed to be in the asset's own currency; TEFAS funds and crypto have no dividend events; withholding is one global rate unless you pass it per manual entry.
261
262
### 8.5.4 Deposits and interest
263
264
A deposit (savings, time or flexible account) is an instrument of type `deposit`: no price feed, one unit = one unit of its currency, valued at its balance. Any currency works (TRY, USD, QAR, ...). The Qatari riyal and the other currencies officially fixed to the dollar (QAR 3.64, SAR 3.75, AED 3.6725, BHD 0.376, OMR 0.3845, JOD 0.709 per USD) always use that fixed rate, never a market quote.
265
266
| Rule | Behaviour |
267 8 Redmine Admin
|---|---|
268 1 Redmine Admin
| Pay in, annual | a buy with the amount, the **yearly rate in percent** (gross), the **term in days** (for example 34 % and 32 days; the field suggests 32, 46, 91, 181, 367) and the **withholding %**; maturity = pay-in date + days. Rate and days must come together (422 otherwise). Price is forced to 1 |
269
| Pay in, monthly (older form) | the **monthly rate**, the term in months (1–120) and the withholding %; still accepted and shown as before |
270 5 Redmine Admin
| Pay in, flexible | `flexible: true` (GUI: *no term, daily-interest account*): no rate, no maturity; only the balance is tracked (see *Update balance*) |
271 8 Redmine Admin
| Expected interest | annual: `amount × rate / 100 × days / 365`; monthly: `amount × monthly rate × months`; minus withholding = net; rounded to 2 decimals. Shown in *Deposits and maturities* (`GET /api/v1/deposits`) with status running, due or booked |
272
| At maturity | per pay-in, changeable until it is settled (`PUT /api/v1/deposits/{id}/action`): `manual` (you book the interest, as before), `close` (principal + net interest are withdrawn), `renew_principal` (same, the principal goes into a new term, the interest leaves as gain), `renew_all` (principal + net interest go into a new term: compounding) |
273
| Automatic booking | for the three automatic actions PF books the net interest **on the maturity date** (note `auto: ...`, the tax in the fee column), withdraws the term and, for a renewal, opens the next term with the **same rate, days, tax and action** (marked *auto-renewed*). It runs when the pages are opened and in the nightly sync; a term missed for several periods is settled period by period, idempotently. The renewal rate is an assumption: if the bank changed it, delete the auto row and enter the real pay-in |
274
| Booking by hand | `manual` terms: press *Book interest* (pre-filled, both values editable to what the bank credited) or use *Interest received* for interest that belongs to no listed term. `POST /api/v1/interest`; a pay-in takes its interest once (409 after) |
275
| Update balance | for an account with daily interest and a rate you do not know: type the balance the bank shows (monthly is enough); the difference to PF's balance is booked as interest (`POST /api/v1/deposits/balance`, optional date). Same balance books nothing; a lower balance is refused (book the withdrawal first) |
276 9 Redmine Admin
| Bank statement import | *Trades → Import a bank statement*: choose the account and the file (`.xlsx`, `.xls` or `.csv`), *Check the file* shows what would be booked, *Import* books it (`POST /api/v1/deposits/import-statement`, form fields `asset_id`, `dry_run`, `file`; 5 MB). Two layouts are recognised: **overnight sweep** (salary account whose spare money goes into a nightly deposit: opening line, then principal, gross interest and tax) and **term deposit** (lines *Faiz Geliri* with "%rate … N günlük" and *Vergi Kesintisi*, plus the balance). Everything else in the file (salary, bills, transfers) is ignored and never stored; the file itself is not kept. Accents are ignored when matching (older lines of one bank write "Acilisi", newer "Açılışı") |
277 11 Redmine Admin
| What an import books | one **interest line per month** (sweep) or **per term** (term deposit): net credited, tax in the fee column, `period_from` and the average balance, so the yearly rate follows; and one **balance line** that sets the balance to the amount swept at the end of that month (a pay-in or withdrawal for the difference; the first one is dated at the start of the first period). Importing the same file again changes nothing; a shorter statement never overwrites a fuller month; the first closing of a sweep file whose opening is before the file starts is left out The preview also **warns about lines typed by hand** on that account inside the period of the file (the statement already holds that money and interest, so they would count twice): delete them under Trades, then import. |
278 9 Redmine Admin
| Interest by month | per account with imported lines: month (or term), balance at the end, gross interest, tax, net, gross and net % per year (simple, scaled to 365 days) and, for accounts not in USD, the **net % per year in USD** and how much the account's currency moved against the dollar in the same days (the day's rate from the market feed; no dollar columns when no rate is available). `GET /api/v1/deposits/interest-summary` |
279 8 Redmine Admin
| Effect | interest raises the balance and counts as **gain**, not as money paid in: `money_invested` stays, `total_return` rises. Withdrawal and re-deposit of a renewal sit on the same day and cancel out |
280
| After booking | the row shows the realized yearly yield after tax |
281 3 Redmine Admin
| Not for deposits | no dividend reinvestment, no technical analysis, no price chart (the Holding page draws the balance) |
282 4 Redmine Admin
283 8 Redmine Admin
Deposit rates change; the latest pay-in's rate is shown as an information line in the market-suggested basket and does not move the basket.
284 1 Redmine Admin
285 5 Redmine Admin
### 8.5.5 Splits and bonus issues
286 4 Redmine Admin
287
A broker books a split or a bonus issue (BIST: *bedelsiz*) by multiplying your share count and dividing the price. PF does the same on its own: **enter the trade the way it happened** (old share count, old price) and PF counts it in today's share unit — quantity × ratio, price ÷ ratio, so the money you paid never changes. Example: 10 ISCTR at 22 TL on 24 Oct 2023, then the 27 Feb 2024 bonus (ratio 2.4999) → PF shows 24.999 shares at 8.80 TL, the same figures as the broker. A trade dated on or after the split day is already in the new unit.
288
289
* **Source:** Yahoo's split list, read when a holder syncs (adding a first purchase, *Check for new dividends now*, the daily run). A new split purges the stored price bars of that instrument (they are fetched again, split-adjusted) and recomputes the dividends of every holder: feed dividends are fetched again in the new unit; reinvestment (DRIP) purchases are always in today's unit; a dividend you typed by hand keeps the money (its shares are counted in the unit of that day).
290
* **Administrator → Splits:** add a split Yahoo has wrong or late (instrument, effective date, *new shares per old share*: 2 for 2-for-1, 2.5 for a 150 % bonus) or remove one. A split that came from Yahoo returns at the next sync unless it is wrong at the source.
291
* **Do not** type the post-split share count of an old trade: it would be multiplied a second time. Trades already entered in the new unit before this release must be changed back to what was bought (Edit on the holding page).
292
* The *Get market price* button gives the price of an old day in the unit of that day (Yahoo's value × the split ratio), so it matches what you paid.
293
294 5 Redmine Admin
### 8.5.6 Ideal basket and the market-suggested basket
295 1 Redmine Admin
296 3 Redmine Admin
The ideal basket is four whole percentages (Deposits, Funds, Stocks, Crypto) that add up to 100, set in *Settings*. The presets are Conservative 45/30/20/5, Balanced 20/35/40/5, Growth 5/20/65/10 (deposits/funds/stocks/crypto); *My own* is stored in `users.ideal_basket`, a preset is the risk profile itself.
297
298 1 Redmine Admin
**Using the suggestion does not move the basket further.** Until 1.1.3 the button saved the tilted basket as the ideal one, and the next suggestion tilted that again (17 → 14 → 11 %). Now the user's own basket is kept in `users.ideal_basket_base` while the suggestion is in use, the suggestion is always worked out from it, *Update to today's market view* refreshes it, and *Back to my own basket* returns. Typing a basket in Settings is the user's own again.
299
300
The **market-suggested basket** starts from the user's own basket and tilts it with a few explainable rules on benchmark closes (cached 6 hours, never stored): a trend filter (price against its 200-day average) and 6-month momentum for **S&P 500** and **BIST 100 read in US dollars** (index ÷ USD/TRY, so inflation does not make a lira index look bullish), **Bitcoin**, and the **VIX** as a risk-off flag. Stocks move up to ±`PF_SUGGEST_MAX_TILT` points, crypto up to ±2.5 per signal point within the same cap; what risk assets gain or lose is taken from / given to deposits and funds, and the total stays exactly 100. Every tilt has its reason in plain words. **Nothing changes until the user presses "Use this as my ideal basket".** Rule-based reading of a few prices, not advice, and it knows nothing about the user's taxes or goals.
301
302 14 Redmine Admin
### 8.5.8 Currencies, costs and bonuses (1.1.13)
303
304
**Every money movement is converted at the rate of its own day; today's value at today's rate.** For the display currency D (the currency switch) and an instrument in currency A, PF keeps the average-cost accounts twice, in A and in D:
305
306
* **Buy:** cost in D = the amount actually paid when the trade was typed in D (`paid_amount`), otherwise (quantity × price + fee) × rate A→D of the trade day.
307
* **Sale:** the share of the cost sold leaves both accounts; proceeds in D at the sale day's rate (or what was received when typed in D); realized P/L in D = proceeds − cost removed. Bought and sold at the same dollar price: no gain in USD, a gain in TRY when the dollar rose in between.
308
* **Dividends and interest:** at the rate of their pay day. **Splits** change the quantity only.
309
* **Value** = quantity × price today × rate today; **unrealized** = value − cost in D; **of which currency effect** = cost in A × rate today − cost in D (the part of the gain that is the currency's move, not the price's; shown on the Portfolio page when not zero).
310
* The **period chart** uses the same rule: each day's value at that day's rate, each payment at its own day's rate.
311
* A **holding's own page** shows its amounts in the instrument's own currency, worked out in that currency.
312
313
Example (the BTC case): 0.00012 BTC for 513.38 TRY on 2025-05-27 (BTC about 108 000 USD, USD/TRY 38.98). In USD the cost is 13.17 USD; in TRY it is exactly 513.38 TRY; today's value is 0.00012 × today's price × today's rate.
314
315
**Bonus:** a buy marked *Bonus (received free)* (source `bonus`): worth the day's close, booked as income on that day (*Gifts and bonuses*), held afterwards like a purchase of that value, never counted as money paid in; sold later, its cost is that value. A corporate bonus issue (bedelsiz) is not this: it is a split (§8.5.5).
316
317 1 Redmine Admin
**Instrument saved in the wrong currency** (before 1.1.13 the form asked for it: `BTC-USD` saved as TRY values a dollar price as lira): the currency selector next to the instrument on the Trades page (`POST assets/{id}/currency`) sets the right currency and converts every trade of it at the rate of its own day; what was paid stays in `paid_currency` / `paid_amount` and the note. Refused while dividends of it exist or another account has trades in it. *Get market price* warns when an instrument's stored currency differs from its source's.
318
319
**Data (1.1.13):** `transactions.paid_currency`, `transactions.paid_amount` (filled at start from the notes of trades typed in another currency, once), source value `bonus`.
320
321
**Deposit renewals (1.1.14).** A renewal withdraws the old term and pays the new one in on the same day (automatic at maturity, or typed by hand). The part paid back in on that day is an **internal transfer**: neither *Money invested* nor proceeds, so *Money invested* and the return percentage are no longer inflated by every renewal. Interest taken out stays a proceed (gain); fresh money added on top stays money in. Existing renewals are counted this way at once (calculated, nothing stored).
322
323
**Days without a price (1.1.14).** A fund in liquidation can be published with price 0 on some days. Those days (and any without a positive close) are left out of charts, risk, analysis and the Market study, and the current price is the last real one. In 1.1.13 such a fund made `/portfolio/risk` and `/portfolio/analysis` answer 500, so the Portfolio page showed *Request failed*.
324
325
**Group split (1.1.14).** `GET /portfolio/history` returns `groups`: per instrument its group (0 US Stocks … 5 Crypto, the page's order), `gain` over the period, `currency` (the instrument's), `fx` (the currency's part: each day yesterday's value in the instrument's currency times that day's move of the rate), `price` (= gain − fx) and today's `value`.
326
327 16 Redmine Admin
### 8.5.9 Midas statements (1.1.15)
328
329
**Where:** *Trades* → *Import Midas statements*. In the Midas app: *Menu → Profile and settings → Documents → Statements*; a statement for a **whole date range** is one PDF (the USD and the TL account are separate statements). Several PDFs or a zip go in one upload; overlapping months count once. *Check the file* shows everything and changes nothing; *Import* books it. Importing the same statements again adds nothing (every line carries an import key). Only the tables are read; the statement's header (name, ID number, address, account numbers) is not kept.
330
331
**Cash accounts.** Each Midas account becomes a cash account in PF, `MIDAS-USD` and `MIDAS-TRY` (type deposit, no term):
332
333
* *Para Yatırma* / *Para Çekme* (from or to a bank) are money paid in / taken out — the only *Money invested* of a broker that PF follows.
334
* A purchase is paid from that cash, a sale goes back to it. Trade and cash line are **broker legs** (source `broker`): neither money in nor money out; the fee is part of the trade.
335
* A dollar purchase or sale between the two accounts is a broker leg on each side (each statement books its own side).
336
* *Nema* (interest on idle cash) is interest of the cash account; its *Stopaj* and other costs (an ADR fee) leave the cash.
337
* A **dividend** is booked with the statement's gross, withholding and net. A purchase of exactly the net amount within a week of the pay day, without fee, is its **reinvestment** (Midas's automatic investment); otherwise the net went into the cash (shown as a dividend, counted once — in the cash balance). Dividends PF had estimated from the market feed for the same instrument up to the statement's last day are replaced; until that day the feed adds none for it, after it the feed goes on, with the reinvestment switch set to what Midas did last.
338
339
**Trades typed by hand** for the same instrument, side and quantity (0.1 % allowed) within 3 days are **linked**: they become broker legs and take the statement's price and fee, nothing is added. Typed trades of these instruments in the statement's period that the statement does not have are listed; *Remove trades I typed …* deletes them in the same import.
340
341 1 Redmine Admin
**Checks.** Per account: the holdings at the statement's end against PF's (split-adjusted), the cash balance against the statement's *Nakit Bakiye*, and the **trades still settling** — a fund sale before its value day, a US purchase of the last day — which the statement's holdings and cash do not show yet; the cash check takes them into account and names what is left unexplained.
342
343 17 Redmine Admin
**Accounts (1.1.16).** Every line can say where it is held — *Held at* on the Trades form and the holding page's *Edit* row, or the account chip on any line of a holding page (`Midas`, `DenizBank`, `Binance` …; empty = none). The import links, removes and counts only lines of Midas or without an account: mark trades held elsewhere (DenizBank funds, a Binance BTC) and the statement never touches them. A trade typed twice (same instrument, side and quantity within 3 days, at the same or no account) is asked about before it is saved.
344 1 Redmine Admin
345 17 Redmine Admin
**Midas Kripto (1.1.16).** Its statement keeps a USD and a TRY balance: cash accounts `MIDAS-CRYPTO-USD` and `MIDAS-CRYPTO-TRY`. A coin is `SYM-USD` (BTC is `BTC-USD`); one bought with lira keeps its price in dollars at that day's rate and what was paid in lira (`paid_currency` / `paid_amount`). Its amounts include the fee (taken apart). A **staking reward** is a bonus of the coin at its value of that day. Midas does not list a gift (the 0.00012 BTC of May 2025): keep the one typed as a bonus.
346
347
**Moves between accounts (1.1.16).** *Hesaplar Arası Transfer* between Midas's own accounts (Menkul USD, Menkul TL, Kripto) is a transfer leg on each side — import all three statements, or the import warns that the money would look lost. Money that came into Midas from one of your **own accounts in PF** (an Enpara withdrawal of the same amount, in the same currency, within a day) is a transfer too: both lines stop being money in and out, so *Money invested* counts that money once. A move 1.1.15 booked as money in or out is corrected by importing the statement again; so is a linked trade that kept the typed quantity.
348
349
**Returns per holding (1.1.16).** A holding paid from a Midas cash account counts that cash as its own money in: its *Total return* is value + cash dividends + what its sales put back − what it cost (1.1.15 showed the whole value). The rows add up to the portfolio's total. A withholding or fee taken from the cash (the Nema's *Stopaj*, an ADR fee) is a cost (source `cost`), not a transfer.
350
351
**Instruments.** A new US instrument is an ETF when Yahoo says so (1.1.16; otherwise a share — *Counts as* on its page changes it). A US symbol is the existing PF one (`BRK.B` is `BRK-B`), a Borsa Istanbul share `SYM.IS`, a fund order (*Fon Emri*) a TEFAS fund; missing ones are created. A fund's price is the amount ÷ the units (the statement's price column has 2 decimals).
352
353 1 Redmine Admin
### 8.5.7 Endpoints (`/api/v1`, bearer token except `login`)
354
355
| Area | Calls |
356 13 Redmine Admin
|---|---|
357 14 Redmine Admin
| Setup | `GET setup/status`, `POST setup/admin` (first run, needs the setup code) |
358 1 Redmine Admin
| Auth | `POST auth/login` (a full login sets the `pf_rt` cookie), `auth/refresh` and `auth/logout` (cookie + header `X-PF-Refresh: 1`), `PUT auth/language` (`""` = the browser's), `auth/2fa/setup` (QR as data URI), `auth/2fa/confirm`, `auth/2fa/disable`, `auth/password`, `auth/recover/request`, `auth/recover/confirm`; `GET auth/me`; `PUT auth/profile` (`risk_profile`, `drip_default`, `display_currency`, `ideal_basket` = `{"deposit":20,"fund":35,"stock":40,"crypto":5}` summing to 100, `reset_ideal_basket`) |
359 14 Redmine Admin
| Labels | `GET /i18n.js` (public: every text, every language), `GET admin/labels` (status, your changes), `GET admin/labels.csv?sep=comma\|semicolon`, `POST admin/labels?dry_run=true\|false` (multipart `file`), `DELETE admin/labels` (administrators) |
360 4 Redmine Admin
| Admin (administrators only) | `GET/POST admin/users`, `POST admin/users/{id}/reset-password` (`remove_2fa`), `reset-2fa`, `require-2fa`, `recovery` (e-mail, self-service), `unlock`, `active` |
361 1 Redmine Admin
| Instruments | `POST assets/{id}/currency` (`currency`: correct a wrong one, trades converted at their days' rates), `GET market/price` (`currency`: the close in it; answers `quote_currency` and `quote_price` too), `GET market/fx?base&quote&on`, `POST assets` (stock/crypto = Yahoo symbol such as `THYAO.IS`, `AAPL`, `BTC-USD`; fund = TEFAS code; deposit = any name, no lookup; looked up on creation, `?verify=false` skips it), `GET assets` |
362 14 Redmine Admin
| Trades | `POST transactions` (buy/sell, quantity, price, fee, trade_date, `paid_currency`, `bonus: true` with side buy and no price = the day's close; selling more than held is refused), `GET transactions` (`asset_id`, `source`, paging), `PUT transactions/{id}` (correct a hand-entered buy or sell, the instrument included), `DELETE transactions/{id}`, `GET activity` (trades, dividends and interest in one list), `GET deposits`, `POST interest` |
363
| Dividends | `GET dividends`, `POST dividends` (`?force=true`), `DELETE dividends/{id}` (hand-entered only), `GET dividends/verify`, `POST dividends/{id}/use-market`, `POST dividends/sync` (6 per hour), `POST dividends/rebuild/{asset_id}`, `GET/PUT holdings/{asset_id}/reinvest` |
364 1 Redmine Admin
| Portfolio | `GET portfolio` (positions, totals in the user's display currency, FX conversion), `currencies`, `portfolio/history?period=1d\|3d\|1w\|1m\|3m\|6m\|ytd\|1y\|all` (points + summary: start/end value, money in, gain, gain %), `portfolio/analysis`, `portfolio/allocation`, `portfolio/rebalance`, `portfolio/suggestion`, `portfolio/risk`, `holdings/{asset_id}/detail` (price or balance series, position, every operation) |
365 17 Redmine Admin
| Accounts | `GET accounts` (the names in use), `PUT transactions/{id}/account` (`account`, any line of yours; `""` = none); `account` on `POST transactions` / `PUT transactions/{id}`, `allow_duplicate` on `POST transactions` (409 `{code: duplicate}` first) |
366 1 Redmine Admin
| Broker statements | `POST import/midas` (multipart `files`: PDFs or a zip, `dry_run=true|false`, `replace_typed=true|false`; answer: per account counts, lines, holdings and cash checks, trades still settling) |
367
| Splits (administrators) | `GET admin/splits` (splits + the stocks/ETFs to pick from), `POST admin/splits` (`asset_id`, `split_date`, `ratio`), `DELETE admin/splits/{id}`; users see the splits of a holding in `GET holdings/{id}/detail` |
368
| Market | `GET market/{asset_id}/indicators`, `market/{asset_id}/history` |
369
370
`portfolio/rebalance` compares the asset-class mix (deposits / funds / stocks / crypto) with the user's ideal basket (a preset of the risk profile or the user's own) and names the amount to add or remove for any class more than 5 points off; `portfolio/suggestion` returns the market-suggested basket with its reasons (§8.5.6). Indicators, rebalancing and suggestions are information, not personal investment advice.
371
372
---
373
374
## Document history
375
376
| Version | Date       | Status     | Changes |
377
|---------|------------|------------|---------|
378 17 Redmine Admin
| 1.20    | 2026-11-04 | Latest     | App 1.1.16: **accounts** on every line (*Held at*), the import works on Midas lines only; **Midas Kripto** statements (two cash balances, coins bought with lira, staking rewards as bonus); moves between Midas accounts and from your own PF accounts are **transfers** (Money invested counted once); a holding's **Total return** counts the cash it was paid from; withholding and fees from the cash are **costs**; linked trades take the statement's quantity (corrected on re-import); a funds' link window of 7 days; a new US instrument that Yahoo calls an ETF is an ETF; the same trade typed twice is asked about; total on the *Edit* row, withholding % on dividend lines (§8.5.9). Image: Debian security updates at build, pip pinned and quiet. Schema: column `transactions.account`, source value `cost` (added by the app; back up first) |
379
| 1.19    | 2026-11-03 | Superseded | App 1.1.15: **Midas statements** (§8.5.9: cash accounts `MIDAS-USD` / `MIDAS-TRY`, broker legs, statement dividends and reinvestments, typed trades linked, holdings and cash checks), server messages in the page's language (§8.5.1), *By group* lines and the percentage over the money at work (§8.5.1), prices refreshed every 15 minutes with their day on the holding page, tax withheld by market (Settings); endpoints (§8.5.7); rollback note (§8.3.1). Schema: source value `broker`, columns `transactions.import_key`, `dividends.to_cash`, `dividends.import_key`, `holding_settings.statement_until`, `users.withholding_bist`, `users.withholding_other` (added by the app; back up first). New package `pypdf` in the image |
380 16 Redmine Admin
| 1.18    | 2026-11-03 | Superseded | App 1.1.14: **Gain by group** under the wealth chart (same period; share of wealth, price part, gain with the currency, currency effect; §8.5.1), **deposit renewals are internal transfers** (Money invested no longer inflated), days with price 0 no longer break risk / analysis (the *Request failed* Portfolio page), Turkish dotted İ in symbols, `.IS` symbols are stocks (§8.5.8). No schema change |
381 15 Redmine Admin
| 1.17    | 2026-11-03 | Superseded | App 1.1.13: **one currency on the trade form** (the instrument's comes from the price source; market price in the chosen currency; changing currency or date refetches or converts), prices rounded to 8 decimals instead of refused, **costs at the rate of their own day** and the exact amount paid when typed in the display currency, *of which currency effect*, holding page in the instrument's own currency (§8.5.8); **Bonus** (§8.5.8); currency correction of an instrument; **languages** (English, Turkish) and the administrators' **Labels** page (§8.5.1); endpoints (§8.5.7); rollback note (§8.3.1). Schema: table `ui_labels`, columns `users.language`, `transactions.paid_currency`, `transactions.paid_amount`, source value `bonus` (added by the app; back up first) |
382 14 Redmine Admin
| 1.16    | 2026-11-02 | Superseded | App 1.1.12: **the login survives a reload** — HttpOnly refresh cookie with rotation, 12 h idle / 7 days absolute, theft detection, every login ends on log out, password / 2FA change, reset and deactivation (§8.5.1); Back no longer needs the in-app guard; endpoints `auth/refresh`, `auth/logout` (§8.5.7). §8.3.1: upgrade from `deploy-publish.sh`, `pf-upgrade.sh` 1.1 (sha256 from `releases/CHECKSUMS.txt`, tidy, rollback text), rollback 1.1.12 → 1.1.11. §8.3.3: the `pg_dump` notice, replacing `pf_db` itself. Schema: table `auth_refresh`, column `users.sessions_valid_after` (added by the app; back up first). App version in the overview corrected (it still said 1.1.8) |
383 12 Redmine Admin
| 1.15    | 2026-11-02 | Superseded | App 1.1.11: the **Price currency** selector on the Trades form and on a holding's *Edit* row (promised in 1.18's notes but never built: only the API had it), *Get market price* converts to the chosen currency, bare crypto symbols look up the `-USD` coin. |
384 11 Redmine Admin
| 1.14    | 2026-11-02 | Superseded | App 1.1.10: **About** unchanged; new: **remove an unused instrument** and **change its type** (Trades form, next to the Instrument list; only while no trade or dividend uses it), typing `BTC` as crypto no longer finds an old plain `BTC` (the fund) but opens the coin `BTC-USD`, the statement preview **warns about hand-typed lines inside the file's period** (they may be counted twice), *Weight by holding* labels are left-aligned and may wrap. No schema change |
385 10 Redmine Admin
| 1.13    | 2026-11-01 | Superseded | App 1.1.9: **About window** (version shown next to the brand in the top bar; click for version, running time, database state, latest stored price date), the price-source message now reads **"Connection timeout: please try later"**, and **Turkish funds** (TEFAS: close price only) get their Market study / suggestion again instead of an ATR error. No schema change |
386 9 Redmine Admin
| 1.12    | 2026-10-31 | Superseded | App 1.1.8: **bank statement import** for an overnight-sweep account and for a term deposit (`.xlsx`, `.xls`, `.csv`; one interest line and one balance line per month or term; §8.5.4), **Interest by month** table (balance, gross, tax, net, yearly rate, and in USD against the currency's move), **price currency** on trades (a TRY purchase of a USD coin is converted at that day's rate), crypto symbols tried as `<SYMBOL>-USD` first, *Currency* column in Positions, *Forgot password* always on the login page, no-term deposit form hides the tax and maturity fields, price-source calls are **retried** twice when the connection drops (a plain message instead of the library's text). Schema: new columns `transactions.period_from`, `avg_balance` (added by the app; back up first); new dependency `xlrd` |
387 8 Redmine Admin
| 1.11    | 2026-10-31 | Superseded | App 1.1.7: **deposits**: yearly rate and term in days (34 % / 32 days) with withholding, **automatic interest at maturity** with a per-term choice (close, renew principal, renew principal + interest, or by hand), deposits in any currency (QAR, SAR, AED, BHD, OMR, JOD use their fixed dollar peg), **Update balance** for daily-interest accounts, no-term (flexible) deposits (§8.5.4). Schema: new columns `transactions.rate_annual`, `term_days`, `at_maturity` (added by the app itself; back up first) |
388 7 Redmine Admin
| 1.10    | 2026-10-31 | Superseded | App 1.1.6: **Market study** — price with averages and Bollinger bands, RSI and MACD on one axis; pointer or tap reads out the indicators, the rules that fired, the score and suggestion **of that day** (no later data used) and what the price did since; your trades on the price line; a *How to read these panels* guide (§8.5.2). Trend and HOLD labels are coloured (bullish green, bearish red, neutral amber). No schema change |
389 6 Redmine Admin
| 1.9     | 2026-10-31 | Superseded | §8.3.1 points to `pf-upgrade.sh` (§5.7 of the host sheet) for the application upgrade; no application change (app 1.1.5). |
390
| 1.8     | 2026-10-31 | Superseded | App 1.1.5: **Holding page** opens with a **My position** card (quantity, total value, average cost, share of the portfolio, today's move, total return with %), the duplicated boxes are gone from the details, amounts are shown in the **asset's own currency** (a BIST stock in TRY, not the display currency), *Unrealized P/L (unsold shares)* and *Realized P/L (from sales)* carry a one-line explanation, and the description columns of the tables are left-aligned and wrap (they were right-aligned and cut off); no schema change |
391 9 Redmine Admin
| 1.7     | 2026-10-31 | Superseded | **Numbered headings, chapter 8 of the dmz-web documentation:** the sections are 8.1 to 8.5 and the former letters are numbers (A to E of Operations = 8.3.1 to 8.3.5; the Using chapter: Web GUI 8.5.1, Pages 8.5.2, DRIP 8.5.3, Deposits 8.5.4, Splits 8.5.5, Ideal basket 8.5.6, Endpoints 8.5.7); every cross-reference updated (`3.C` is now §8.3.3, `3.D` §8.3.4). No application change (app 1.1.4). |
392 3 Redmine Admin
| 1.6     | 2026-10-31 | Superseded | App 1.1.4: **splits and bonus issues** (read from Yahoo, trades stay as entered and are counted in today's share unit; administrator page *Splits*; §8.5.5); Positions and Holdings analysis **grouped** (US Stocks, ETFs, BIST, Turkish Funds, Interest, Crypto) with separator rows; **hover or tap readout** on the wealth, holding and Market charts and tooltips on the bar charts; **Edit** on the holding page (instrument, date, quantity, price, fee); the **All** period percentage is gain over the capital at work (modified Dietz) instead of over the first day's tiny value; the **market-suggested basket** no longer drifts each time it is adopted (`users.ideal_basket_base`, *Back to my own basket*); *Get market price* returns the price in the unit of that day. New table `asset_splits`, new column `users.ideal_basket_base`; tarball `pf-app-1.1.8.tar.gz`. |
393 1 Redmine Admin
| 1.5     | 2026-10-31 | Superseded | App 1.1.3: browser **Back** moves between the screens of the app instead of leaving it (the login token lives in memory, so leaving the page logged you out); **Delete** on the holding page; Trades history **by year, closed by default, with filters** and a row limit per year; **Commissions paid** (counted in the cost, shown separately); the **Positions** and **Holdings analysis** tables start sorted by instrument A to Z and sort by any column header (click again to reverse), the holding selector is A to Z too; trade-form text for Quantity + Total, the total with the fee shown before you press the button, and the Instrument box starting empty; **exchange rates** for pairs Yahoo does not quote (TRY/QAR): the reverse pair inverted, then through the US dollar, and for currencies pegged to the dollar (QAR 3.64, SAR 3.75, AED 3.6725, BHD 0.376, OMR 0.3845) the official peg when there is no quote at all; the stray word "null" on the Trades page; §3.A: wait loop for the health check, the `sed` for the unit, which commands are root only; the tarball is `pf-app-1.1.3.tar.gz`. No schema change. |
394
| 1.4     | 2026-10-31 | Superseded | App 1.1.2: **ideal basket** in Settings (presets as radio buttons or "My own") with four classes incl. **deposits**, "add / remove" summary and the **market-suggested basket** (S&P 500, BIST 100 in USD, Bitcoin, VIX; cap `PF_SUGGEST_MAX_TILT`); **deposits** with monthly rate, term and withholding, **interest** booked by hand, *Deposits and maturities*; **Holding pages** with price chart, operation markers and every operation; **dividend check** against market data, delete and duplicate warning for hand-entered dividends; fractional shares to **9 decimals**; **Currencies** page for the administrator (pick from a list or type a code; a code is accepted only when Yahoo has a rate for it; the list feeds the display-currency switch and the currency dropdown of new instruments); trade form with **Total** and **Get market price for this date** (close of that day, or the last trading day before it; you can overtype it, and typing a price yourself always works); (any two of quantity / price / total, the third is calculated); instrument type **fund / ETF on Yahoo** (counts as fund in the allocation, priced and paying dividends like a stock; an existing stock can be switched on its holding page, *Counts as*); a newly bought stock fetches its **dividend history at once**; `BRK.B`-style symbols are found as `BRK-B`; new look for charts (soft fill, dashed start level, end dot, large figures) and the self-hosted Inter font. Migrations: `users.ideal_basket`, five `transactions` columns, enum values `deposit`, `etf` and `interest`, share columns widened to 9 decimals, new table `app_settings` (idempotent). §3.A: upgrade check commands and rollback caveat; §4: new entries; §5 B-1 and B-2; 51 tests. `pf-install.sh` 1.3. |
395
| 1.3     | 2026-10-29 | Superseded | App 1.1.1: **web GUI** (first-run administrator page with setup code, roles User / Administrator / Owner, QR enrolment, mandatory 2FA for users and optional for administrators, forced password change), **total wealth** with currency switch and period chart (green / red), **holdings analysis with BUY / HOLD / SELL suggestions**, admin-assignable **e-mail recovery** (optional SMTP), e-mail notice and 24 h password-change lock after 2FA removal, "reset password and remove 2FA" in one step. Seven added `users` columns (idempotent). `pf-install.sh` 1.2 (image tag 1.1.1, commented SMTP lines, first-run "Next" text). §5 rewritten; §3.A upgrade from the tarball; §4 entries; config variables; 38 tests. |
396
| 1.2     | 2026-10-29 | Superseded | The real application replaces the starter: login with TOTP 2FA (rate limit, lockout, replay protection, no public registration), transactions, **dividend reinvestment (DRIP)**, Yahoo/TEFAS market data on a TimescaleDB hypertable, positions and allocation in the base currency, indicators, risk statistics, class-level rebalancing hints, admin CLI. `pf-install.sh` 1.1: `--app-src` instead of `--init-app`, image tag 1.1.0, longer start period. New §5 (users and 2FA, DRIP rules, endpoint list); §2 file map, config variables, tests; §4 entries for secret key, 429, authenticator codes, `pending_price`. Pins: `greenlet` added (SQLAlchemy async), `PyJWT`/`bcrypt` instead of python-jose/passlib. |
397
| 1.1     | 2026-10-29 | Superseded | Rewritten around `pf-install.sh` (one procedure instead of nine manual steps, with the live-vs-repo NPM `Network=` guard). Two networks (`net-pf-db` is `Internal=true`); DB healthcheck and `Notify=healthy`; explicit `.volume` unit; pinned images (`2.31.0-pg16`, `site-pf:<tag>`) and a fully pinned, wheels-only `requirements.txt`; secrets as hex (URL-safe) and the URL built with `quote`; `/docs` off; Access List option; Timescale-aware backup/restore, password rotation and DB upgrade; troubleshooting; history table. Fixes of the 1.0 draft: wrong `diff` expectation (`+` vs `>`), NPM snippet repeating section headers, unpinned `latest` tags. |
398
| 1.0     | 2026-10-29 | Superseded | Draft (manual nine-step procedure). Never committed. |