Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
249 lines (209 loc) · 12.1 KB
/
Copy pathMakefile
File metadata and controls
249 lines (209 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# PortfolioDB — one entry point for the containerized stack.
#
# make list every target
# make up start postgres + dashboard + scheduler
# make schema create/refresh the tables
# make positions show current positions
# make add-lot ARGS="--symbol NVDA --account IBKR --trade-date 2026-02-13 --side BUY --qty 1 --price 184"
#
# Targets that take arguments read them from ARGS, so anything the underlying
# CLI accepts works without teaching this file about it.
COMPOSE ?= docker compose
# Where `make init` tells you to fetch a missing .env.template from.
RAW := https://raw.githubusercontent.com/amosgeva/PortfolioDB/main
# Building from source is the contributor path, so it needs the dev overlay.
COMPOSE_DEV := $(COMPOSE) -f docker-compose.yml -f docker-compose.dev.yml
# One-shot container for CLIs: same image as the dashboard, removed after use.
RUN := $(COMPOSE) run --rm dashboard
PSQL := $(COMPOSE) exec -T postgres psql -q -U portfoliouser -d portfoliodb
BACKUP_DIR ?= backups
.DEFAULT_GOAL := help
.PHONY: help init up down restart build pull dev-up ps logs schema psql shell test \
positions add-lot sell-lot set-cash watchlist snapshot brief ask \
report demo-seed mcp tools backup restore ro-role lock
help: ## Show this list
@grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[1m%-12s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo " Pass CLI flags with ARGS=\"...\" — e.g. make positions ARGS=\"--symbol NVDA\""
# ── first run ────────────────────────────────────────────────────────────
init: ## First run: fill .env with generated secrets (safe to re-run)
@if [ ! -e .env ]; then \
if [ -e .env.template ]; then \
cp .env.template .env; \
echo "Created .env from .env.template."; \
else \
echo "No .env here, and no .env.template to copy from."; \
echo "Fetch one:"; \
echo " curl -fsSL $(RAW)/.env.template -o .env"; \
exit 1; \
fi; \
fi
@set -e; \
read_key() { sed -n "s/^$$1=//p" .env | head -1; }; \
gen() { head -c 48 /dev/urandom | base64 | tr -d '/+=' | cut -c1-"$$1"; }; \
write_key() { sed -i.bak -e "s|^$$1=.*|$$1=$$2|" .env; rm -f .env.bak; }; \
pg=$$(read_key POSTGRES_PASSWORD); app=$$(read_key PORTFOLIODB_PASSWORD); \
if [ -z "$$pg" ] && [ -z "$$app" ]; then \
pw=$$(gen 22); write_key POSTGRES_PASSWORD "$$pw"; write_key PORTFOLIODB_PASSWORD "$$pw"; \
echo "Generated a Postgres password and set both keys to it."; \
elif [ -z "$$app" ]; then \
write_key PORTFOLIODB_PASSWORD "$$pg"; \
echo "PORTFOLIODB_PASSWORD was empty — set it to match POSTGRES_PASSWORD."; \
elif [ -z "$$pg" ]; then \
write_key POSTGRES_PASSWORD "$$app"; \
echo "POSTGRES_PASSWORD was empty — set it to match PORTFOLIODB_PASSWORD."; \
elif [ "$$pg" != "$$app" ]; then \
echo "WARNING: POSTGRES_PASSWORD and PORTFOLIODB_PASSWORD are different."; \
echo " Postgres will start and the app will fail to connect."; \
echo " Left both alone — make them equal by hand."; \
else \
echo "Postgres password already set — left alone."; \
fi; \
if [ -z "$$(read_key PORTFOLIODB_MCP_TOKEN)" ]; then \
write_key PORTFOLIODB_MCP_TOKEN "$$(gen 40)"; \
echo "Generated an MCP token."; \
else \
echo "MCP token already set — left alone."; \
fi; \
chmod 600 .env; \
echo "Tightened permissions on .env to 600."
@echo ""
@echo "Next:"
@echo " 1. make up && make schema"
@echo " 2. make demo-seed # fictional data to look at, skip for a real ledger"
@echo " 3. open http://localhost:8501"
@echo ""
@echo " Optional: an LLM API key in .env enables the advisor"
@echo " (docs/llm-providers.md), and your investor one-pager is"
@echo " pasted into the dashboard's Advisor tab (docs/philosophy.md)."
# ── stack ────────────────────────────────────────────────────────────────
up: ## Start postgres + dashboard + scheduler (detached)
$(COMPOSE) up -d
down: ## Stop everything (keeps the database volume)
$(COMPOSE) down
restart: ## Recreate the app containers, e.g. after changing .env
$(COMPOSE) up -d --force-recreate dashboard scheduler
pull: ## Fetch the latest published application image
$(COMPOSE) pull
build: ## Build the application image from source (contributors)
PORTFOLIODB_VERSION=$$(git rev-parse --short HEAD 2>/dev/null || echo dev) $(COMPOSE_DEV) build
dev-up: ## Start the stack from a locally built image
$(COMPOSE_DEV) up -d
# Regenerated INSIDE the image, never from a host interpreter: the lock has to
# be the resolution the image's Python and platform produce, or CI's
# "image matches the lock" check rejects it. Builds first so the freeze reflects
# the current requirements.txt rather than the last image on disk.
lock: ## Refresh app/constraints.txt from a fresh image build (after editing requirements.txt)
$(COMPOSE_DEV) build dashboard
@{ sed -n '/^#/p' app/constraints.txt; \
$(COMPOSE_DEV) run --rm --no-deps dashboard pip freeze --all --exclude-editable; } > app/constraints.txt.new \
&& mv app/constraints.txt.new app/constraints.txt \
&& echo "app/constraints.txt refreshed: $$(grep -vc '^#' app/constraints.txt) pins — review the diff and commit"
ps: ## Show service status
$(COMPOSE) ps
logs: ## Follow logs — make logs ARGS=scheduler for one service
$(COMPOSE) logs -f $(ARGS)
mcp: ## Start the optional MCP server (:8765)
$(COMPOSE) --profile mcp up -d mcp
tools: ## Start the optional pgAdmin browser (:58080)
$(COMPOSE) --profile tools up -d pgadmin
# ── database ─────────────────────────────────────────────────────────────
schema: ## Create/refresh tables, then apply migrations in order
$(RUN) python app/apply_schema.py $(ARGS)
psql: ## Open an interactive psql shell
$(COMPOSE) exec postgres psql -U portfoliouser -d portfoliodb
demo-seed: ## Load a fictional portfolio so a fresh install has something to show
$(RUN) python app/demo_seed.py --yes $(ARGS)
ro-role: ## Create the read-only role for the MCP server (PASSWORD= optional)
$(RUN) python app/create_ro_role.py $(if $(PASSWORD),--password $(PASSWORD),--generate)
# ── backup / restore ─────────────────────────────────────────────────────
# Three checks, because a pipeline's exit status is its LAST command's: a
# pg_dump that died still fed gzip a valid, non-empty archive of nothing, and
# `test -s` waved it through. So (1) the dump and gzip run under `bash -o
# pipefail` inside the container, which makes a producer failure the pipeline's
# failure; (2) the archive must decompress; (3) it must carry pg_dump's own
# completion marker, which a dump killed mid-way never writes. The file is built
# under a .part name and renamed only once all three pass, so a failed run never
# leaves something that looks like a backup.
backup: ## pg_dump the database, gzipped and verified — make backup ARGS=/other/dir
@dir="$(if $(ARGS),$(ARGS),$(BACKUP_DIR))"; \
mkdir -p "$$dir"; \
out="$$dir/portfoliodb-$$(date +%Y%m%d-%H%M%S).sql.gz"; \
if ! $(COMPOSE) exec -T postgres bash -o pipefail -c \
'pg_dump -U portfoliouser -d portfoliodb | gzip -c' > "$$out.part"; then \
rm -f "$$out.part"; \
echo "backup FAILED: pg_dump or gzip exited non-zero — nothing was written. Is postgres running?"; \
exit 1; \
fi; \
if ! gzip -t "$$out.part" 2>/dev/null; then \
rm -f "$$out.part"; echo "backup FAILED: the archive is not a valid gzip — nothing was kept"; exit 1; \
fi; \
if ! gunzip -c "$$out.part" | grep -q 'PostgreSQL database dump complete'; then \
rm -f "$$out.part"; \
echo "backup FAILED: the dump has no completion marker, so pg_dump did not finish — nothing was kept"; \
exit 1; \
fi; \
mv "$$out.part" "$$out"; \
echo "wrote $$out ($$(du -h "$$out" | cut -f1))"; \
echo "Copy it off this machine, and keep .env + philosophy.md with it."
restore: ## Restore a dump into an EMPTY database — make restore ARGS=backups/x.sql.gz
@test -n "$(ARGS)" || { echo "usage: make restore ARGS=backups/portfoliodb-....sql.gz"; exit 1; }
@test -f "$(ARGS)" || { echo "no such file: $(ARGS)"; exit 1; }
@n=$$($(PSQL) -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public'" | tr -d '[:space:]'); \
if [ "$$n" != "0" ]; then \
echo "Refusing to restore: the database already has $$n table(s)."; \
echo "Restoring over a live ledger is how data gets lost twice."; \
echo "To rebuild from scratch: make down && docker volume rm portfoliodb_pgdata && make up"; \
exit 1; \
fi
@gzip -t "$(ARGS)" 2>/dev/null || { \
echo "not a valid gzip: $(ARGS) — the archive is truncated or corrupt, nothing was restored"; exit 1; }
@# psql keeps going after a SQL error by default and exits 0, so a half-loaded
@# database used to print "restored". ON_ERROR_STOP makes the first error fatal
@# (exit 3) and --single-transaction rolls everything back, leaving the target
@# as empty as the guard above found it. The archive was verified whole just
@# above, which is why a mid-stream gunzip failure needs no separate check.
@#
@# A dump taken from a database whose public schema had been dropped and
@# recreated carries `CREATE SCHEMA public;`, which a fresh database rejects —
@# an error psql used to skip and now stops on. The guard proved the target
@# holds no tables, so when the dump wants to create the schema, drop the empty
@# one first, inside the same transaction so a failed restore puts it back.
@pre=""; \
if gunzip -c "$(ARGS)" | grep -q '^CREATE SCHEMA public;'; then pre="DROP SCHEMA IF EXISTS public CASCADE;"; fi; \
if ! { echo "$$pre"; gunzip -c "$(ARGS)"; } | $(PSQL) -X -v ON_ERROR_STOP=1 --single-transaction; then \
echo "restore FAILED: psql stopped at the first error and rolled back — the database is still empty"; \
exit 1; \
fi
@echo "restored $(ARGS)"
@$(PSQL) -tAc "SELECT 'lots: '||count(*) FROM lots"
# ── ledger ───────────────────────────────────────────────────────────────
positions: ## Current positions (FIFO)
$(RUN) python app/positions.py $(ARGS)
add-lot: ## Record a BUY/SELL lot — needs ARGS
$(RUN) python app/add_lot.py $(ARGS)
sell-lot: ## Record a sale against open lots — needs ARGS
$(RUN) python app/sell_lot.py $(ARGS)
set-cash: ## Record a cash balance — needs ARGS
$(RUN) python app/set_cash.py $(ARGS)
watchlist: ## Track symbols you don't hold — make watchlist ARGS="NVDA AMD"
$(RUN) python app/set_watchlist.py $(ARGS)
# ── jobs (the scheduler runs these too) ───────────────────────────────────
snapshot: ## Collect prices now, ignoring the market window
$(RUN) python app/snapshot_prices.py --ignore-window $(ARGS)
brief: ## Generate an advisor brief now
$(RUN) python app/advisor.py brief $(ARGS)
ask: ## Ask the advisor — make ask ARGS="what's my concentration risk?"
$(RUN) python app/advisor.py ask $(ARGS)
report: ## Write the end-of-day text report
$(RUN) python app/report_portfolio_db.py $(ARGS)
# ── development ──────────────────────────────────────────────────────────
shell: ## Shell inside a throwaway app container
$(COMPOSE) run --rm --entrypoint sh dashboard
test: ## Run both test suites inside the container
$(COMPOSE) run --rm dashboard sh -c '\
pip install --user --quiet pytest && \
export PATH=$$PATH:/home/appuser/.local/bin && \
echo "── app suite ──" && cd /app/app && python -m pytest tests/ -q && \
echo "── MCP suite ──" && cd /app && python -m pytest app/mcp/tests/ -m "not slow" -q'