PF » History » Version 2
Redmine Admin, 10/31/2026 10:15 AM
deploy-publish.sh
| 1 | 2 | Redmine Admin | **Version 1.3 — 2026-10-29** · companion of the host cheat sheet (conventions: [[Conventions]], [[NPM]], [[Operations]], [[Git_Workflow]]) |
|---|---|---|---|
| 2 | 1 | Redmine Admin | |
| 3 | 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. |
||
| 4 | |||
| 5 | | Item | Value | |
||
| 6 | |---|---| |
||
| 7 | | Public name | `pf.sibernet.com.tr` (DNS A record → FortiGate WAN IP; Let's Encrypt HTTP-01) | |
||
| 8 | 2 | 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.1 | |
| 9 | 1 | Redmine Admin | | Database | `db-pf` — TimescaleDB on PostgreSQL 16, `docker.io/timescale/timescaledb:2.31.0-pg16` | |
| 10 | | 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) | |
||
| 11 | | Volume | `db-pf-data` (PostgreSQL data) | |
||
| 12 | | Secrets | `pf-db-password`, `pf-app-secret` (both `openssl rand -hex 32`, created without a trailing newline) | |
||
| 13 | | Installer | `pf-install.sh` in `~/deploy` (this file's procedures are what it does) | |
||
| 14 | | Source | `pf-app-<ver>.tar.gz` (delivered with the release, not part of `dmz-web-deploy`); lives afterwards in its own repo | |
||
| 15 | |||
| 16 | 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. |
||
| 17 | |||
| 18 | --- |
||
| 19 | |||
| 20 | ## 1. Install — `pf-install.sh` (websvcmaster) |
||
| 21 | |||
| 22 | First time, from `~/deploy` (shell setup: host cheat sheet §0), with the source tarball next to the script: |
||
| 23 | |||
| 24 | ```bash |
||
| 25 | 2 | Redmine Admin | ./pf-install.sh --app-src=pf-app-1.1.1.tar.gz --build --access-list=admin-segments |
| 26 | 1 | Redmine Admin | ``` |
| 27 | |||
| 28 | * `--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). |
||
| 29 | 2 | Redmine Admin | * `--build` runs `podman build -t localhost/site-pf:1.1.1 ~/apps/pf` (the default tag; `--app-image=localhost/site-pf:<tag>` for another). |
| 30 | * `--access-list=NAME` puts that NPM Access List on the new Proxy Host. The app has its own GUI login (§5), so this is a second layer, not the only one — still recommended while the host is new. |
||
| 31 | 1 | Redmine Admin | * Other options: `--db-image`, `--app-dir`, `--timezone`, `--skip-npm-api`, `--force`; `./pf-install.sh -h`. |
| 32 | |||
| 33 | 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. |
||
| 34 | |||
| 35 | 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. |
||
| 36 | |||
| 37 | 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). |
||
| 38 | |||
| 39 | Then commit the generated units (root): `deploy-commit.sh -m "pf: portfolio stack on pf.sibernet.com.tr"`. |
||
| 40 | |||
| 41 | 2 | Redmine Admin | Open the site and create the administrator on the first-run page — §5. Verify: |
| 42 | 1 | Redmine Admin | |
| 43 | ```bash |
||
| 44 | curl -sI --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444 | head -1 # HTTP/2 200 |
||
| 45 | curl -s --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444/health # {"status":"healthy"} |
||
| 46 | podman exec site-pf curl -s http://127.0.0.1:8000/health/db # {"status":"db ok"} |
||
| 47 | ./check-sites.sh | grep pf |
||
| 48 | podman network inspect net-pf net-pf-db | jq '.[].containers | keys' # net-pf: site, nginx-proxy-manager · net-pf-db: db, site |
||
| 49 | ``` |
||
| 50 | |||
| 51 | --- |
||
| 52 | |||
| 53 | ## 2. Application code and images |
||
| 54 | |||
| 55 | 2 | 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. |
| 56 | 1 | Redmine Admin | |
| 57 | Source layout (`~/apps/pf`): |
||
| 58 | |||
| 59 | | File | Job | |
||
| 60 | |---|---| |
||
| 61 | | `app/config.py` | `PF_*` settings; DB URL built with `quote_plus`, so a password with `/ + = @ :` cannot break it | |
||
| 62 | | `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 | |
||
| 63 | | `app/auth.py` | bcrypt (SHA-256 pre-hash), TOTP with replay protection, JWT, per-IP rate limit, per-account lockout | |
||
| 64 | | `app/services/market_data.py` | Yahoo Finance (stocks, crypto, FX, dividends) and TEFAS (funds); raw, not dividend-adjusted prices | |
||
| 65 | | `app/services/drip.py` | dividend reinvestment (§5) | |
||
| 66 | | `app/services/portfolio.py`, `indicators.py` | positions, allocation, rebalancing hints, risk; RSI/MACD/SMA/EMA/Bollinger/ATR (pandas-ta) | |
||
| 67 | 2 | Redmine Admin | | `app/services/mailer.py` | optional SMTP sender (notices, recovery links); silent when `PF_SMTP_HOST` is empty | |
| 68 | 1 | Redmine Admin | | `app/main.py`, `app/schemas.py`, `app/cli.py` | API, validation, admin commands | |
| 69 | 2 | 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) | |
| 70 | | `tests/` | 38 tests (`pytest`), see "Tests" below | |
||
| 71 | 1 | Redmine Admin | |
| 72 | * **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. |
||
| 73 | * **Image tags:** one tag per release (`site-pf:1.1.0`, `1.1.1`, …), never `latest` — the previous tag is the rollback. |
||
| 74 | 2 | Redmine Admin | * **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`: the choices in the currency switch; any other 3-letter code with a Yahoo FX rate also works, e.g. via `PUT /auth/profile`), `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_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. |
| 75 | 1 | 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 Git A–E) and tag it with the same number as the image. |
| 76 | 2 | Redmine Admin | * **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). Nothing is dropped or rewritten; **take a `pg_dump` (3.C) before the first start of a new version anyway.** |
| 77 | 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. |
| 78 | |||
| 79 | --- |
||
| 80 | |||
| 81 | ## 3. Operations |
||
| 82 | |||
| 83 | 2 | Redmine Admin | ### A. New application version |
| 84 | 1 | Redmine Admin | |
| 85 | 2 | Redmine Admin | The source is delivered as `pf-app-<ver>.tar.gz`. The old source stays aside as the rollback: |
| 86 | |||
| 87 | 1 | Redmine Admin | ```bash |
| 88 | 2 | Redmine Admin | # 0. backup first: 3.C (pg_dump -Fc), copy it off the host |
| 89 | cd ~ && mv apps/pf apps/pf-1.1.0 # keep the old tree |
||
| 90 | mkdir apps/pf && tar -xzf ~/deploy/pf-app-1.1.1.tar.gz -C apps/pf --no-same-owner --no-same-permissions |
||
| 91 | podman build -t localhost/site-pf:1.1.1 ~/apps/pf |
||
| 92 | # repo copy: change Image=localhost/site-pf:1.1.1 in ~/deploy/quadlet/site-pf.container (keep HealthStartPeriod=90s) |
||
| 93 | 1 | Redmine Admin | # root: deploy-commit.sh -m "pf: app 1.1.1" |
| 94 | cp ~/deploy/quadlet/site-pf.container ~/.config/containers/systemd/ |
||
| 95 | 2 | Redmine Admin | systemctl --user daemon-reload && systemctl --user restart site-pf.service # only site-pf, not db-pf and not NPM |
| 96 | podman logs site-pf | tail -20 |
||
| 97 | 1 | Redmine Admin | ~/deploy/check-sites.sh | grep pf |
| 98 | ``` |
||
| 99 | |||
| 100 | 2 | Redmine Admin | Rollback: put the previous tag back in the unit (same four lines). The added columns are harmless to the old version. |
| 101 | 1 | Redmine Admin | |
| 102 | ### B. Logs and shell |
||
| 103 | |||
| 104 | ```bash |
||
| 105 | journalctl --user -u site-pf.service -f |
||
| 106 | podman logs -f site-pf podman logs -f db-pf |
||
| 107 | podman exec -it db-pf psql -U pf_user -d pf_db |
||
| 108 | ``` |
||
| 109 | |||
| 110 | ### C. Backup and restore — TimescaleDB is not plain PostgreSQL |
||
| 111 | |||
| 112 | A plain dump restored the plain way breaks hypertables. Use the custom format and restore with the Timescale pre/post calls: |
||
| 113 | |||
| 114 | ```bash |
||
| 115 | # backup (websvcmaster) — then copy the file OFF the host; a dump next to the database is not a backup |
||
| 116 | podman exec db-pf pg_dump -U pf_user -Fc pf_db > ~/tmp/pf_db-$(date +%F).dump |
||
| 117 | |||
| 118 | # restore into an EMPTY database (same TimescaleDB version or newer) |
||
| 119 | podman exec db-pf psql -U pf_user -d postgres -c "create database pf_restore" |
||
| 120 | podman exec db-pf psql -U pf_user -d pf_restore -c "create extension if not exists timescaledb" |
||
| 121 | podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_pre_restore()" |
||
| 122 | podman exec -i db-pf pg_restore -U pf_user -d pf_restore --no-owner < ~/tmp/pf_db-2026-10-29.dump |
||
| 123 | podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_post_restore()" |
||
| 124 | ``` |
||
| 125 | |||
| 126 | **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). |
||
| 127 | |||
| 128 | ### D. Password rotation |
||
| 129 | |||
| 130 | `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`. |
||
| 131 | |||
| 132 | ### E. Upgrading the database image |
||
| 133 | |||
| 134 | 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. |
||
| 135 | |||
| 136 | --- |
||
| 137 | |||
| 138 | ## 4. Troubleshooting |
||
| 139 | |||
| 140 | * **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`. |
||
| 141 | * **`/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 3.D, or drop the volume if the database is still empty. |
||
| 142 | * **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". |
||
| 143 | * **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. |
||
| 144 | * **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). |
||
| 145 | 2 | 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` (a new one on every start, valid until used). |
| 146 | * **The chart for 1D / 3D shows few points** → prices are daily closes; there is no intraday data. 1W and longer are the meaningful periods. |
||
| 147 | 1 | 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`. |
| 148 | * **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`). |
||
| 149 | 2 | 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. |
| 150 | 1 | 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`. |
| 151 | * **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. |
||
| 152 | |||
| 153 | --- |
||
| 154 | |||
| 155 | ## 5. Using the application |
||
| 156 | |||
| 157 | 2 | Redmine Admin | ### A. Web GUI, roles and first run |
| 158 | 1 | Redmine Admin | |
| 159 | 2 | 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). |
| 160 | 1 | Redmine Admin | |
| 161 | 2 | Redmine Admin | **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): |
| 162 | |||
| 163 | 1 | Redmine Admin | ```bash |
| 164 | 2 | Redmine Admin | podman logs site-pf | grep FIRST-RUN # code is valid until used; a new one each start |
| 165 | 1 | Redmine Admin | ``` |
| 166 | |||
| 167 | 2 | Redmine Admin | The administrator created there is the **Owner** (user name `admin` unless typed otherwise). |
| 168 | 1 | Redmine Admin | |
| 169 | 2 | Redmine Admin | **Roles.** |
| 170 | 1 | Redmine Admin | |
| 171 | 2 | Redmine Admin | | | User | Administrator | Owner | |
| 172 | |---|---|---|---| |
||
| 173 | | Portfolio, trades, dividends, charts, analysis | yes | **no** (403 on every data endpoint) | no | |
||
| 174 | | Create / deactivate accounts, set temporary passwords, reset 2FA, require 2FA, set e-mail / recovery, unlock | no | yes, for users | yes, also for administrators | |
||
| 175 | | Create other administrators | no | no | yes | |
||
| 176 | | 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 | |
||
| 177 | 1 | Redmine Admin | |
| 178 | 2 | Redmine Admin | 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. |
| 179 | |||
| 180 | **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). |
||
| 181 | |||
| 182 | **Lost password / lost authenticator.** |
||
| 183 | |||
| 184 | | Case | What happens | |
||
| 185 | |---|---| |
||
| 186 | | Lost password | Administrator: *Users → Reset a password* (temporary password, forced change at the next login). | |
||
| 187 | | 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`). | |
||
| 188 | | 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. | |
||
| 189 | | 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. | |
||
| 190 | | Nothing configured | Without `PF_SMTP_HOST` no mail is sent, the login page shows no recovery links, and the administrator path above is the only one. | |
||
| 191 | |||
| 192 | 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. |
||
| 193 | |||
| 194 | **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. |
||
| 195 | |||
| 196 | ### B-0. Pages (user) |
||
| 197 | |||
| 198 | * **Portfolio** — *Total wealth* 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 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 is not a win). The dashed line is the net money paid in. Prices are daily closes, so 1D and 3D have few points. Below: totals, positions, **Holdings: market analysis and suggestion**, asset mix versus the risk profile, weight by holding, risk. |
||
| 199 | * **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. Information, not investment advice. |
||
| 200 | * **Trades**, **Dividends** (reinvestment switch per holding, sync, chart), **Market** (price chart and indicators per instrument), **Settings** (risk profile, reinvestment default, 2FA, password). |
||
| 201 | * Administrators see **Users** and **My account** only. |
||
| 202 | |||
| 203 | 1 | Redmine Admin | ### B. Dividend reinvestment (DRIP) |
| 204 | |||
| 205 | | Rule | Behaviour | |
||
| 206 | |---|---| |
||
| 207 | | 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) | |
||
| 208 | | Entitlement | shares held at the close **before** the ex-date; a purchase on the ex-date earns nothing | |
||
| 209 | | Amount | `shares × per-share × (1 − withholding)` | |
||
| 210 | | 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 6 decimals; the rest is kept as `cash_residual` | |
||
| 211 | | Reinvest off | the net amount is recorded as cash income (`status: paid`) | |
||
| 212 | | No price yet | `status: pending_price`; the daily sync completes it (the later dividends of that asset wait, so the share count stays right) | |
||
| 213 | | Compounding | reinvested shares are normal shares and earn the next dividend | |
||
| 214 | | Idempotent | one row per user, asset and ex-date — syncing twice does nothing | |
||
| 215 | | Manual entry | `POST /api/v1/dividends` for companies the feed does not know; optional `withholding`, `reinvest`, `reinvest_price` | |
||
| 216 | | 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) | |
||
| 217 | | 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` | |
||
| 218 | |||
| 219 | Limits to know: Yahoo reports ex-dates only, so the pay date is `ex-date + PF_DIVIDEND_PAY_LAG_DAYS` (0); 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. |
||
| 220 | |||
| 221 | ### C. Endpoints (`/api/v1`, bearer token except `login`) |
||
| 222 | |||
| 223 | | Area | Calls | |
||
| 224 | |---|---| |
||
| 225 | 2 | Redmine Admin | | Setup | `GET setup/status`, `POST setup/admin` (first run, needs the setup code) | |
| 226 | | Auth | `POST auth/login`, `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`: conservative / balanced / growth, `drip_default`, `display_currency`) | |
||
| 227 | | 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` | |
||
| 228 | 1 | Redmine Admin | | Instruments | `POST assets` (stock/crypto = Yahoo symbol such as `THYAO.IS`, `AAPL`, `BTC-USD`; fund = TEFAS code; looked up on creation, `?verify=false` skips it), `GET assets` | |
| 229 | | Trades | `POST transactions` (buy/sell, quantity, price, fee, trade_date; selling more than held is refused), `GET transactions` (`asset_id`, `source`, paging), `DELETE transactions/{id}` | |
||
| 230 | | Dividends | `GET dividends`, `POST dividends`, `POST dividends/sync` (6 per hour), `POST dividends/rebuild/{asset_id}`, `GET/PUT holdings/{asset_id}/reinvest` | |
||
| 231 | 2 | 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/risk` | |
| 232 | 1 | Redmine Admin | | Market | `GET market/{asset_id}/indicators`, `market/{asset_id}/history` | |
| 233 | |||
| 234 | `portfolio/rebalance` compares the asset-class mix (funds / stocks / crypto) with an illustrative target for the chosen risk profile (conservative 65/30/5, balanced 40/50/10, growth 20/65/15) and names the amount to move for any class more than 5 points off. Indicators and rebalancing are information, not personal investment advice. |
||
| 235 | |||
| 236 | --- |
||
| 237 | |||
| 238 | ## Document history |
||
| 239 | |||
| 240 | | Version | Date | Status | Changes | |
||
| 241 | |---------|------------|------------|---------| |
||
| 242 | 2 | Redmine Admin | | 1.3 | 2026-10-29 | Latest | 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. | |
| 243 | | 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. | |
||
| 244 | 1 | Redmine Admin | | 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. | |
| 245 | | 1.0 | 2026-10-29 | Superseded | Draft (manual nine-step procedure). Never committed. | |