PF » History » Revision 8
« Previous |
Revision 8/17
(diff)
| Next »
Redmine Admin, 11/01/2026 01:59 AM
deploy-publish.sh
Chapter 8 of the dmz-web documentation · headings are numbered like the cheat sheets · index: Wiki
- Table of contents
- 8.1 Install — pf-install.sh (websvcmaster)
- 8.2 Application code and images
- 8.3 Operations
- 8.4 Troubleshooting
- 8.5 Using the application
- Document history
Version 1.11 — 2026-10-31 · companion of the host cheat sheet (conventions: Conventions, NPM, Operations, Git_Workflow)
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.
| Item | Value |
|---|---|
| Public name |
pf.sibernet.com.tr (DNS A record → FortiGate WAN IP; Let's Encrypt HTTP-01) |
| 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.7 |
| Database |
db-pf — TimescaleDB on PostgreSQL 16, docker.io/timescale/timescaledb:2.31.0-pg16
|
| 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) |
| Volume |
db-pf-data (PostgreSQL data) |
| Secrets |
pf-db-password, pf-app-secret (both openssl rand -hex 32, created without a trailing newline) |
| Installer |
pf-install.sh in ~/deploy (this file's procedures are what it does) |
| Source |
pf-app-<ver>.tar.gz (delivered with the release, not part of dmz-web-deploy); lives afterwards in its own repo |
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.
8.1 Install — pf-install.sh (websvcmaster)¶
First time, from ~/deploy (shell setup: host cheat sheet §0), with the source tarball next to the script:
./pf-install.sh --app-src=pf-app-1.1.7.tar.gz --build --access-list=admin-segments
-
--app-src=FILEunpacks the application (Containerfile, pinnedrequirements.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). -
--buildrunspodman build -t localhost/site-pf:1.1.7 ~/apps/pf(the default tag;--app-image=localhost/site-pf:<tag>for another). -
--access-list=NAMEputs 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. - Other options:
--db-image,--app-dir,--timezone,--skip-npm-api,--force;./pf-install.sh -h.
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.
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.
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).
Then commit the generated units (root): deploy-commit.sh -m "pf: portfolio stack on pf.sibernet.com.tr".
Open the site and create the administrator on the first-run page — §8.5.1. Verify:
curl -sI --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444 | head -1 # HTTP/2 200
curl -s --resolve pf.sibernet.com.tr:444:127.0.0.1 https://pf.sibernet.com.tr:444/health # {"status":"healthy"}
podman exec site-pf curl -s http://127.0.0.1:8000/health/db # {"status":"db ok"}
./check-sites.sh | grep pf
podman network inspect net-pf net-pf-db | jq '.[].containers | keys' # net-pf: site, nginx-proxy-manager · net-pf-db: db, site
8.2 Application code and images¶
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.
Source layout (~/apps/pf):
| File | Job |
|---|---|
app/config.py |
PF_* settings; DB URL built with quote_plus, so a password with / + = @ : cannot break it |
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 |
app/auth.py |
bcrypt (SHA-256 pre-hash), TOTP with replay protection, JWT, per-IP rate limit, per-account lockout |
app/services/market_data.py |
Yahoo Finance (stocks, crypto, FX, dividends) and TEFAS (funds); raw, not dividend-adjusted prices |
app/services/drip.py |
dividend reinvestment (§8.5.3) |
app/services/portfolio.py, indicators.py
|
positions, allocation, ideal basket and rebalancing hints (four classes), risk; RSI/MACD/SMA/EMA/Bollinger/ATR (pandas-ta) |
app/services/suggest.py |
market-suggested basket: benchmark trend rules, tilt capped by PF_SUGGEST_MAX_TILT (§8.5.6) |
app/services/mailer.py |
optional SMTP sender (notices, recovery links); silent when PF_SMTP_HOST is empty |
app/main.py, app/schemas.py, app/cli.py
|
API, validation, admin commands |
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) |
tests/ |
54 tests (pytest), see "Tests" below |
-
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 includesgreenlet, 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-joseandpasslib(unmaintained; passlib breaks with current bcrypt) — the app usesPyJWTandbcryptdirectly. -
Image tags: one tag per release (
site-pf:1.1.0,1.1.1, …), neverlatest— the previous tag is the rollback. -
Config (
PF_*, set insite-pf.container):PF_BASE_CURRENCY(defaultTRY; the default display currency of new users),PF_CURRENCIES(defaultTRY,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, soQARworks),PF_DIVIDEND_WITHHOLDING(fraction taken at source, default0— set it to what your broker really withholds, e.g.0.10),PF_DRIP_DEFAULT(defaulttrue),PF_DIVIDEND_PAY_LAG_DAYS(default0),PF_SYNC_ENABLED/PF_SYNC_INTERVAL_HOURS(daily),PF_REQUIRE_2FA(defaulttrue: end users must enrol 2FA, administrators may;false: optional for everybody — an administrator can still require it per account),PF_SUGGEST_MAX_TILT(default10, hard cap20: 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; defaulthttps://<PF_DOMAIN>),PF_LOGIN_RATE_LIMIT(default5/minute),PF_ACCESS_TOKEN_MINUTES(30),PF_ENABLE_DOCS(0: hides/docs,/redoc,/openapi.json;1only while developing, never behind the public Proxy Host). The app refuses to start whenPF_SECRET_KEY(secretpf-app-secret) is shorter than 32 characters. -
Source code is not in
dmz-web-deploy. Keep~/apps/pfin 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. -
Tables:
create_allat start creates what is missing. Later columns are added by a short list of idempotentALTER TABLE … ADD COLUMN IF NOT EXISTSstatements inapp/database.pythat runs at every start (release 1.1.1 adds seven columns tousers: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 addsusers.ideal_basket;transactions.rate_monthly,withholding_pct,term_months,maturity_date,parent_id(deposits and interest); the enum valuesasset_type = depositandetfandtx_source = interest(ALTER TYPE … ADD VALUE IF NOT EXISTS); the share columnstransactions.quantity,dividends.sharesanddividends.reinvest_quantityare widened toNUMERIC(27,9); and the new tableapp_settings(the administrator's currency list). Release 1.1.3 changes no schema. Release 1.1.4 adds the tableasset_splits(created bycreate_all: one row per split or bonus issue of an instrument) and the columnusers.ideal_basket_base(ADD COLUMN IF NOT EXISTS; set only while a user runs the market-suggested basket). Nothing is dropped or rewritten; take apg_dump(§8.3.3) before the first start of a new version anyway. -
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, withrequirements-dev.txtinstalled 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.
8.3 Operations¶
8.3.1 New application version¶
Short way (from 1.13): as root, pf-upgrade.sh <version> does the steps below, asks before each one and keeps you in the root shell (§5.7). The manual steps stay valid.
The source is delivered as pf-app-<ver>.tar.gz. The old source stays aside as the rollback:
# 0. backup first: §8.3.3 (pg_dump -Fc), copy it off the host
# (root) the tarball goes to ~websvcmaster/deploy first: cp pf-app-1.1.7.tar.gz /home/websvcmaster/deploy/ && chown websvcmaster: /home/websvcmaster/deploy/pf-app-1.1.7.tar.gz
cd ~ && mv apps/pf apps/pf-1.1.5 # keep the old tree
mkdir apps/pf && tar -xzf ~/deploy/pf-app-1.1.7.tar.gz -C apps/pf --no-same-owner --no-same-permissions
podman build -t localhost/site-pf:1.1.7 ~/apps/pf
# repo copy (websvcmaster): change the Image= line, keep HealthStartPeriod=90s
sed -i 's#^Image=localhost/site-pf:.*#Image=localhost/site-pf:1.1.7#' ~/deploy/quadlet/site-pf.container
# root (not websvcmaster: the script is root-only): deploy-commit.sh -m "pf: app 1.1.7"
cp ~/deploy/quadlet/site-pf.container ~/.config/containers/systemd/
systemctl --user daemon-reload && systemctl --user restart site-pf.service # only site-pf, not db-pf and not NPM
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
podman logs site-pf | tail -20
~/deploy/check-sites.sh | grep pf
Check the result (websvcmaster):
~/deploy/check-sites.sh | grep pf # HTTP 200 / healthy
podman exec db-pf psql -U pf_user -d pf_db -c "\d users" | grep ideal_basket
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"
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
podman exec db-pf psql -U pf_user -d pf_db -c "\d transactions" | grep quantity # numeric(27,9)
podman exec db-pf psql -U pf_user -d pf_db -c "\dt" | grep app_settings
podman exec db-pf psql -U pf_user -d pf_db -c "select hypertable_name from timescaledb_information.hypertables" # price_history
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).
Rollback: put the previous tag back in the unit (same four lines). 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.
8.3.2 Logs and shell¶
journalctl --user -u site-pf.service -f
podman logs -f site-pf podman logs -f db-pf
podman exec -it db-pf psql -U pf_user -d pf_db
8.3.3 Backup and restore — TimescaleDB is not plain PostgreSQL¶
A plain dump restored the plain way breaks hypertables. Use the custom format and restore with the Timescale pre/post calls:
# backup (websvcmaster) — then copy the file OFF the host; a dump next to the database is not a backup
podman exec db-pf pg_dump -U pf_user -Fc pf_db > ~/tmp/pf_db-$(date +%F).dump
# restore into an EMPTY database (same TimescaleDB version or newer)
podman exec db-pf psql -U pf_user -d postgres -c "create database pf_restore"
podman exec db-pf psql -U pf_user -d pf_restore -c "create extension if not exists timescaledb"
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_pre_restore()"
podman exec -i db-pf pg_restore -U pf_user -d pf_restore --no-owner < ~/tmp/pf_db-2026-10-29.dump
podman exec db-pf psql -U pf_user -d pf_restore -c "select timescaledb_post_restore()"
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).
8.3.4 Password rotation¶
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.
8.3.5 Upgrading the database image¶
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.
8.4 Troubleshooting¶
-
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); checkpodman healthcheck run db-pfandpodman logs db-pf. -
/healthis 200 but/health/dbis 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. -
NPM 502 for the pf host → NPM is not on
net-pf(podman network inspect net-pf | jq '.[0].containers | keys'); check theNetwork=net-pf.networkline in the livenginx-proxy-manager.containerand that NPM was restarted afterwards. Other sites also 502 → host cheat sheet §7 "NPM says 502". - 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.
-
App container exits at once, log says
PF_SECRET_KEY ... shorter than 32 characters→ thepf-app-secretsecret is missing or empty; recreate it (openssl rand -hex 32 | tr -d '\n' | podman secret create pf-app-secret -afterpodman secret rm pf-app-secret) and restartsite-pf.service. Existing login tokens become invalid (30 minutes anyway). -
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 userfor every administrator — the page appears when no administrator exists; the setup code is inpodman 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). - The chart for 1D / 3D shows few points → prices are daily closes; there is no intraday data. 1W and longer are the meaningful periods.
-
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. -
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 theContainerfile). -
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. -
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. -
pg_dumpprints 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 -llists the tables, the row counts match). -
podman buildprints "Running pip as the 'root' user …" → harmless; it happens inside the image build, not on the host. - 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.
-
"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. -
Build fails on a dependency → no wheel for Python 3.12 (the
--only-binaryguard); pick a version with a wheel, do not install a compiler into the image.
8.5 Using the application¶
8.5.1 Web GUI, roles and first run¶
Open https://pf.sibernet.com.tr. Everything the API can do is in the GUI (the API stays for a later mobile app).
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):
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
The administrator created there is the Owner (user name admin unless typed otherwise).
Roles.
| User | Administrator | Owner | |
|---|---|---|---|
| Portfolio, trades, dividends, charts, analysis | yes | no (403 on every data endpoint) | no |
| Create / deactivate accounts, set temporary passwords, reset 2FA, require 2FA, set e-mail / recovery, unlock | no | yes, for users | yes, also for administrators |
| Create other administrators | no | no | yes |
| 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 |
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.
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).
Lost password / lost authenticator.
| Case | What happens |
|---|---|
| Lost password | Administrator: Users → Reset a password (temporary password, forced change at the next login). |
| 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). |
| 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. |
| 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. |
| 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. |
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.
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.
8.5.2 Pages (user)¶
-
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. 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). - 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, 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.
- 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.
- 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.
- Trades — 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. 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.
- 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.
- 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; 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).
- Administrators see Users and My account only.
8.5.3 Dividend reinvestment (DRIP)¶
| Rule | Behaviour |
|---|---|
| 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) |
| Entitlement | shares held at the close before the ex-date; a purchase on the ex-date earns nothing |
| Amount | shares × per-share × (1 − withholding) |
| 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
|
| Reinvest off | the net amount is recorded as cash income (status: paid) |
| No price yet |
status: pending_price; the daily sync completes it (the later dividends of that asset wait, so the share count stays right) |
| Compounding | reinvested shares are normal shares and earn the next dividend |
| Idempotent | one row per user, asset and ex-date — syncing twice does nothing |
| 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 |
| 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) |
| 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
|
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.
8.5.4 Deposits and interest¶
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.
| Rule | Behaviour |
|---|---|
| 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 |
| Pay in, monthly (older form) | the monthly rate, the term in months (1–120) and the withholding %; still accepted and shown as before |
| Pay in, flexible |
flexible: true (GUI: no term, daily-interest account): no rate, no maturity; only the balance is tracked (see Update balance) |
| 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 |
| 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) |
| 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 |
| 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) |
| 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) |
| 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 |
| After booking | the row shows the realized yearly yield after tax |
| Not for deposits | no dividend reinvestment, no technical analysis, no price chart (the Holding page draws the balance) |
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.
8.5.5 Splits and bonus issues¶
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.
- 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).
- 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.
- 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).
- 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.
8.5.6 Ideal basket and the market-suggested basket¶
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.
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.
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.
8.5.7 Endpoints (/api/v1, bearer token except login)¶
| Area | Calls |
|---|---|
| Setup |
GET setup/status, POST setup/admin (first run, needs the setup code) |
| 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, drip_default, display_currency, ideal_basket = {"deposit":20,"fund":35,"stock":40,"crypto":5} summing to 100, reset_ideal_basket) |
| 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
|
| Instruments |
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
|
| Trades |
POST transactions (buy/sell, quantity, price, fee, trade_date; 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
|
| 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
|
| 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) |
| 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
|
| Market |
GET market/{asset_id}/indicators, market/{asset_id}/history
|
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.
Document history¶
| Version | Date | Status | Changes |
|---|---|---|---|
| 1.11 | 2026-10-31 | Latest | 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) |
| 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 |
| 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). |
| 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 |
| 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). |
| 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.7.tar.gz. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 1.0 | 2026-10-29 | Superseded | Draft (manual nine-step procedure). Never committed. |
Updated by Redmine Admin 5 days ago · 17 revisions