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'); } };