diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..82ff38b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+# Local wallet scripts with credentials — never commit
+btc_wallet.py
diff --git a/btc-portfolio/backend/.dockerignore b/btc-portfolio/backend/.dockerignore
new file mode 100644
index 0000000..9b5dfdb
--- /dev/null
+++ b/btc-portfolio/backend/.dockerignore
@@ -0,0 +1,7 @@
+.git
+__pycache__
+*.pyc
+*.pyo
+.env
+*.egg-info
+.pytest_cache
diff --git a/btc-portfolio/backend/app/auth.py b/btc-portfolio/backend/app/auth.py
index 7d1993e..9e3dc53 100644
--- a/btc-portfolio/backend/app/auth.py
+++ b/btc-portfolio/backend/app/auth.py
@@ -1,8 +1,9 @@
+import os
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
-SECRET_KEY = "change-me-in-production-use-a-long-random-string"
+SECRET_KEY = os.environ.get("SECRET_KEY", "dev-insecure-key-change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
diff --git a/btc-portfolio/backend/app/main.py b/btc-portfolio/backend/app/main.py
index b785c2f..caab39d 100644
--- a/btc-portfolio/backend/app/main.py
+++ b/btc-portfolio/backend/app/main.py
@@ -17,8 +17,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allow_headers=["Content-Type", "Authorization"],
)
app.include_router(users.router)
diff --git a/btc-portfolio/backend/app/routes/purchases.py b/btc-portfolio/backend/app/routes/purchases.py
index bad5c7a..f8c3c65 100644
--- a/btc-portfolio/backend/app/routes/purchases.py
+++ b/btc-portfolio/backend/app/routes/purchases.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from typing import List
from datetime import datetime
@@ -12,13 +12,13 @@ router = APIRouter()
class PurchaseCreate(BaseModel):
- amount_eur: float
- price_eur: float
+ amount_eur: float = Field(gt=0, le=10_000_000)
+ price_eur: float = Field(gt=0, le=10_000_000)
class PurchaseUpdate(BaseModel):
- amount_eur: float
- price_eur: float
+ amount_eur: float = Field(gt=0, le=10_000_000)
+ price_eur: float = Field(gt=0, le=10_000_000)
created_at: datetime
diff --git a/btc-portfolio/backend/app/routes/users.py b/btc-portfolio/backend/app/routes/users.py
index d78d118..4395342 100644
--- a/btc-portfolio/backend/app/routes/users.py
+++ b/btc-portfolio/backend/app/routes/users.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from ..database import get_db
from .. import models
@@ -10,6 +10,11 @@ router = APIRouter()
class UserCreate(BaseModel):
+ username: str = Field(min_length=3, max_length=50)
+ password: str = Field(min_length=8)
+
+
+class UserLogin(BaseModel):
username: str
password: str
@@ -37,7 +42,7 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
@router.post("/login", response_model=Token)
-def login(user_in: UserCreate, db: Session = Depends(get_db)):
+def login(user_in: UserLogin, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.username == user_in.username).first()
if not user or not verify_password(user_in.password, user.password):
raise HTTPException(status_code=401, detail="Invalid credentials")
diff --git a/btc-portfolio/backend/app/services/btc.py b/btc-portfolio/backend/app/services/btc.py
index 20d50f0..02f471c 100644
--- a/btc-portfolio/backend/app/services/btc.py
+++ b/btc-portfolio/backend/app/services/btc.py
@@ -1,6 +1,9 @@
+import logging
import requests
from datetime import datetime, timezone
+logger = logging.getLogger(__name__)
+
def get_btc_history_eur() -> list:
try:
@@ -11,7 +14,8 @@ def get_btc_history_eur() -> list:
)
resp.raise_for_status()
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
- except Exception:
+ except Exception as e:
+ logger.error(f"Failed to fetch BTC history: {e}")
return []
@@ -25,7 +29,8 @@ def get_btc_ohlc_eur(days: int) -> list:
)
resp.raise_for_status()
return resp.json() # [[timestamp_ms, open, high, low, close], ...]
- except Exception:
+ except Exception as e:
+ logger.error(f"Failed to fetch BTC OHLC: {e}")
return []
@@ -58,5 +63,6 @@ def get_btc_price_eur() -> float:
)
resp.raise_for_status()
return float(resp.json()["bitcoin"]["eur"])
- except Exception:
+ except Exception as e:
+ logger.error(f"Failed to fetch BTC price: {e}")
return 0.0
diff --git a/btc-portfolio/backend/app/services/candles.py b/btc-portfolio/backend/app/services/candles.py
index 434a569..31016ad 100644
--- a/btc-portfolio/backend/app/services/candles.py
+++ b/btc-portfolio/backend/app/services/candles.py
@@ -1,10 +1,10 @@
import logging
-from datetime import datetime, timezone, date as dt_date
+from datetime import datetime, timezone, timedelta, date as dt_date
from sqlalchemy.orm import Session
from ..models import OHLCCandle
-from .btc import get_btc_ohlc_eur, aggregate_to_daily
+from .btc import get_btc_ohlc_eur, aggregate_to_daily, get_btc_history_eur
logger = logging.getLogger(__name__)
@@ -30,10 +30,54 @@ def seed_candles(db: Session) -> None:
logger.info("Candle seed: stored %d daily candles (%s → %s).", len(rows), min(daily.keys()), max(daily.keys()))
+def seed_historical_prices(db: Session) -> None:
+ """Backfill up to 365 days of daily close prices from CoinGecko market_chart.
+ Uses previous day's close as each day's open to produce red/green candles.
+ Clears entries older than 31 days on each run so the data stays fresh.
+ Real OHLC entries (last 30 days) are never touched.
+ """
+ raw = get_btc_history_eur()
+ if not raw:
+ logger.warning("Historical price seed: CoinGecko returned no data.")
+ return
+
+ prices = {}
+ for ts_ms, price in raw:
+ date = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d")
+ prices[date] = price
+
+ # Remove stale historical entries (older than 31 days) so they get re-seeded with current data
+ cutoff = (datetime.now(tz=timezone.utc) - timedelta(days=31)).strftime("%Y-%m-%d")
+ db.query(OHLCCandle).filter(OHLCCandle.date < cutoff).delete()
+ db.commit()
+
+ existing = {c.date for c in db.query(OHLCCandle).all()}
+
+ new_rows = []
+ prev_close = None
+ for date, close in sorted(prices.items()):
+ if date in existing:
+ prev_close = close
+ continue
+ open_ = prev_close if prev_close is not None else close
+ high = max(open_, close)
+ low = min(open_, close)
+ new_rows.append(OHLCCandle(date=date, open=open_, high=high, low=low, close=close))
+ prev_close = close
+
+ if new_rows:
+ db.add_all(new_rows)
+ db.commit()
+ logger.info("Historical price seed: stored %d daily entries (%s → %s).", len(new_rows), new_rows[0].date, new_rows[-1].date)
+
+
def refresh_latest_candles(db: Session) -> None:
"""Add any missing candles up to today. Seeds the DB if empty.
Also detects and replaces coarse (>2-day gap) legacy data from a previous days=365 seed.
"""
+ # Always backfill historical prices for dates not yet in DB (no-op once populated)
+ seed_historical_prices(db)
+
# Sparse-data detection: if existing candles have >2-day gaps, wipe and re-seed
first_two = db.query(OHLCCandle).order_by(OHLCCandle.date.asc()).limit(2).all()
if len(first_two) == 2:
diff --git a/btc-portfolio/docker-compose.yml b/btc-portfolio/docker-compose.yml
index d126cfc..1308903 100644
--- a/btc-portfolio/docker-compose.yml
+++ b/btc-portfolio/docker-compose.yml
@@ -7,7 +7,14 @@ services:
- ./data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
+ - SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/')"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 15s
frontend:
build:
@@ -19,3 +26,9 @@ services:
depends_on:
- backend
restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "wget", "-qO-", "http://localhost:3001/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 20s
diff --git a/btc-portfolio/frontend/.dockerignore b/btc-portfolio/frontend/.dockerignore
new file mode 100644
index 0000000..ae12c86
--- /dev/null
+++ b/btc-portfolio/frontend/.dockerignore
@@ -0,0 +1,6 @@
+.git
+node_modules
+build
+.env
+.env.local
+npm-debug.log
diff --git a/btc-portfolio/frontend/Dockerfile b/btc-portfolio/frontend/Dockerfile
index 42fb6de..7f73f59 100644
--- a/btc-portfolio/frontend/Dockerfile
+++ b/btc-portfolio/frontend/Dockerfile
@@ -10,7 +10,10 @@ RUN npm run build
FROM node:18-alpine
RUN npm install -g serve
+RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=build /app/build ./build
+RUN chown -R appuser:appgroup /app
+USER appuser
EXPOSE 3001
CMD ["serve", "-s", "build", "-l", "3001"]
diff --git a/btc-portfolio/frontend/src/components/AddPurchase.js b/btc-portfolio/frontend/src/components/AddPurchase.js
index 6b1e04b..a4c1add 100644
--- a/btc-portfolio/frontend/src/components/AddPurchase.js
+++ b/btc-portfolio/frontend/src/components/AddPurchase.js
@@ -39,7 +39,8 @@ export default function AddPurchase({ onAdded }) {
setAmountEur('');
setPriceEur('');
onAdded();
- } catch {
+ } catch (err) {
+ console.error('AddPurchase network error:', err);
setError('Network error');
}
};
diff --git a/btc-portfolio/frontend/src/components/PortfolioChart.js b/btc-portfolio/frontend/src/components/PortfolioChart.js
index f8a0ffd..628e70c 100644
--- a/btc-portfolio/frontend/src/components/PortfolioChart.js
+++ b/btc-portfolio/frontend/src/components/PortfolioChart.js
@@ -19,40 +19,84 @@ const styles = {
saveBtn: { marginTop: '0.75rem', background: 'none', border: '1px solid #555', color: '#aaa', borderRadius: '6px', padding: '0.4rem 1rem', cursor: 'pointer', fontSize: '0.85rem' },
};
-export default function PortfolioChart({ purchases, stats }) {
+function toDateKey(date) {
+ return date.toISOString().split('T')[0];
+}
+
+function priceOn(date, priceMap, currentPrice, isToday, sortedPurchases) {
+ if (isToday) return currentPrice || 0;
+ // Try candle history (walk back up to 7 days)
+ for (let i = 0; i <= 7; i++) {
+ const d = new Date(date);
+ d.setDate(d.getDate() - i);
+ const p = priceMap[toDateKey(d)];
+ if (p) return p;
+ }
+ // Fall back to most recent purchase price up to this date
+ let fallback = null;
+ for (const p of sortedPurchases) {
+ const pd = new Date(p.created_at);
+ pd.setHours(0, 0, 0, 0);
+ if (pd <= date) fallback = p.price_eur;
+ }
+ return fallback;
+}
+
+export default function PortfolioChart({ purchases, stats, btcHistory }) {
const chartRef = useRef(null);
if (!purchases || purchases.length === 0) return null;
- // Build cumulative data points sorted by date
const sorted = [...purchases].sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // Build price lookup from candle history
+ const priceMap = {};
+ (btcHistory || []).forEach(({ date, close }) => { priceMap[date] = close; });
+
+ const firstDate = new Date(sorted[0].created_at);
+ firstDate.setHours(0, 0, 0, 0);
+
+ // Generate biweekly dates from first purchase to today
+ const dates = [];
+ const cursor = new Date(firstDate);
+ while (cursor <= today) {
+ dates.push(new Date(cursor));
+ cursor.setDate(cursor.getDate() + 7);
+ }
+ if (toDateKey(dates[dates.length - 1]) !== toDateKey(today)) {
+ dates.push(new Date(today));
+ }
- let cumInvested = 0;
- let cumBtc = 0;
const labels = [];
const portfolioValues = [];
const investedValues = [];
- sorted.forEach((p, i) => {
- cumInvested += p.amount_eur;
- cumBtc += p.amount_eur / p.price_eur;
- const currentVal = cumBtc * (stats?.current_price || p.price_eur);
- labels.push(new Date(p.created_at).toLocaleDateString());
- portfolioValues.push(parseFloat(currentVal.toFixed(2)));
+ dates.forEach(date => {
+ const isToday = toDateKey(date) === toDateKey(today);
+ const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted);
+ if (price === null) return; // no price data available, skip
+
+ // Cumulative BTC and invested up to this date
+ let cumBtc = 0;
+ let cumInvested = 0;
+ sorted.forEach(p => {
+ const pDate = new Date(p.created_at);
+ pDate.setHours(0, 0, 0, 0);
+ if (pDate <= date) {
+ cumBtc += p.amount_eur / p.price_eur;
+ cumInvested += p.amount_eur;
+ }
+ });
+
+ if (cumBtc === 0) return; // no purchases yet at this date
+
+ labels.push(date.toLocaleDateString('en-GB'));
+ portfolioValues.push(parseFloat((cumBtc * price).toFixed(2)));
investedValues.push(parseFloat(cumInvested.toFixed(2)));
});
- const todayLabel = new Date().toLocaleDateString();
- if (labels.length === 0 || labels[labels.length - 1] !== todayLabel) {
- const currentVal = cumBtc * (stats?.current_price || 0);
- labels.push(todayLabel);
- portfolioValues.push(parseFloat(currentVal.toFixed(2)));
- investedValues.push(parseFloat(cumInvested.toFixed(2)));
- }
-
- const currentPrice = stats?.current_price || 0;
- const breakEvenLine = labels.map(() => stats?.average_price || 0);
-
const data = {
labels,
datasets: [
diff --git a/btc-portfolio/frontend/src/pages/AdminPage.js b/btc-portfolio/frontend/src/pages/AdminPage.js
index 810dab0..0152842 100644
--- a/btc-portfolio/frontend/src/pages/AdminPage.js
+++ b/btc-portfolio/frontend/src/pages/AdminPage.js
@@ -66,7 +66,12 @@ export default function AdminPage() {
const handleDelete = async (id, name) => {
if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return;
- await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() });
+ const res = await fetch(`${API}/admin/users/${id}`, { method: 'DELETE', headers: authHeaders() });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setError(data.detail || 'Failed to delete user');
+ return;
+ }
fetchUsers();
};
diff --git a/btc-portfolio/frontend/src/pages/Dashboard.js b/btc-portfolio/frontend/src/pages/Dashboard.js
index dd5baff..4b3762c 100644
--- a/btc-portfolio/frontend/src/pages/Dashboard.js
+++ b/btc-portfolio/frontend/src/pages/Dashboard.js
@@ -135,7 +135,7 @@ export default function Dashboard() {
>{label}
))}
- {(chartView === 'both' || chartView === 'portfolio') && }
+ {(chartView === 'both' || chartView === 'portfolio') && }
{(chartView === 'both' || chartView === 'history') && (
navigate('/login'), 1500);
- } catch {
+ } catch (err) {
+ console.error('Register network error:', err);
setError('Network error');
}
};