Compare commits
6 Commits
4616accc63
...
33656c4512
| Author | SHA1 | Date | |
|---|---|---|---|
| 33656c4512 | |||
| 5cf3726f59 | |||
| ce227e47d6 | |||
| 11b020907d | |||
| 4da23c9def | |||
| 85455f3271 |
@@ -0,0 +1,2 @@
|
|||||||
|
# Local wallet scripts with credentials — never commit
|
||||||
|
btc_wallet.py
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
*.egg-info
|
||||||
|
.pytest_cache
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
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"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 1 day
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ app.add_middleware(
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=origins,
|
allow_origins=origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
allow_headers=["*"],
|
allow_headers=["Content-Type", "Authorization"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import List
|
from typing import List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -12,13 +12,13 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class PurchaseCreate(BaseModel):
|
class PurchaseCreate(BaseModel):
|
||||||
amount_eur: float
|
amount_eur: float = Field(gt=0, le=10_000_000)
|
||||||
price_eur: float
|
price_eur: float = Field(gt=0, le=10_000_000)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseUpdate(BaseModel):
|
class PurchaseUpdate(BaseModel):
|
||||||
amount_eur: float
|
amount_eur: float = Field(gt=0, le=10_000_000)
|
||||||
price_eur: float
|
price_eur: float = Field(gt=0, le=10_000_000)
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from .. import models
|
from .. import models
|
||||||
@@ -10,6 +10,11 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
|
username: str = Field(min_length=3, max_length=50)
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
@@ -37,7 +42,7 @@ def register(user_in: UserCreate, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=Token)
|
@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()
|
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):
|
if not user or not verify_password(user_in.password, user.password):
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import logging
|
||||||
import requests
|
import requests
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_btc_history_eur() -> list:
|
def get_btc_history_eur() -> list:
|
||||||
try:
|
try:
|
||||||
@@ -11,7 +14,8 @@ def get_btc_history_eur() -> list:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
|
return resp.json().get("prices", []) # [[timestamp_ms, price], ...]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch BTC history: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +29,8 @@ def get_btc_ohlc_eur(days: int) -> list:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json() # [[timestamp_ms, open, high, low, close], ...]
|
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 []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -58,5 +63,6 @@ def get_btc_price_eur() -> float:
|
|||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return float(resp.json()["bitcoin"]["eur"])
|
return float(resp.json()["bitcoin"]["eur"])
|
||||||
except Exception:
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch BTC price: {e}")
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import logging
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import OHLCCandle
|
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__)
|
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()))
|
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:
|
def refresh_latest_candles(db: Session) -> None:
|
||||||
"""Add any missing candles up to today. Seeds the DB if empty.
|
"""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.
|
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
|
# 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()
|
first_two = db.query(OHLCCandle).order_by(OHLCCandle.date.asc()).limit(2).all()
|
||||||
if len(first_two) == 2:
|
if len(first_two) == 2:
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
- DATABASE_URL=sqlite:////app/data/btc_portfolio.db
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-dev-insecure-key-change-me}
|
||||||
restart: unless-stopped
|
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:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -19,3 +26,9 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3001/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log
|
||||||
@@ -10,7 +10,10 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
RUN npm install -g serve
|
RUN npm install -g serve
|
||||||
|
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/build ./build
|
COPY --from=build /app/build ./build
|
||||||
|
RUN chown -R appuser:appgroup /app
|
||||||
|
USER appuser
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["serve", "-s", "build", "-l", "3001"]
|
CMD ["serve", "-s", "build", "-l", "3001"]
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export default function AddPurchase({ onAdded }) {
|
|||||||
setAmountEur('');
|
setAmountEur('');
|
||||||
setPriceEur('');
|
setPriceEur('');
|
||||||
onAdded();
|
onAdded();
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('AddPurchase network error:', err);
|
||||||
setError('Network error');
|
setError('Network error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' },
|
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);
|
const chartRef = useRef(null);
|
||||||
|
|
||||||
if (!purchases || purchases.length === 0) return 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 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 labels = [];
|
||||||
const portfolioValues = [];
|
const portfolioValues = [];
|
||||||
const investedValues = [];
|
const investedValues = [];
|
||||||
|
|
||||||
sorted.forEach((p, i) => {
|
dates.forEach(date => {
|
||||||
cumInvested += p.amount_eur;
|
const isToday = toDateKey(date) === toDateKey(today);
|
||||||
cumBtc += p.amount_eur / p.price_eur;
|
const price = priceOn(date, priceMap, stats?.current_price, isToday, sorted);
|
||||||
const currentVal = cumBtc * (stats?.current_price || p.price_eur);
|
if (price === null) return; // no price data available, skip
|
||||||
labels.push(new Date(p.created_at).toLocaleDateString());
|
|
||||||
portfolioValues.push(parseFloat(currentVal.toFixed(2)));
|
// 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)));
|
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 = {
|
const data = {
|
||||||
labels,
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
|
|||||||
@@ -66,7 +66,12 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
const handleDelete = async (id, name) => {
|
const handleDelete = async (id, name) => {
|
||||||
if (!window.confirm(`Delete user "${name}"? This also deletes all their purchases.`)) return;
|
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();
|
fetchUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function Dashboard() {
|
|||||||
>{label}</button>
|
>{label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} />}
|
{(chartView === 'both' || chartView === 'portfolio') && <PortfolioChart purchases={purchases} stats={stats} btcHistory={candles?.candles ?? []} />}
|
||||||
{(chartView === 'both' || chartView === 'history') && (
|
{(chartView === 'both' || chartView === 'history') && (
|
||||||
<BTCCandlestickChart
|
<BTCCandlestickChart
|
||||||
candles={activeCandles?.candles ?? null}
|
candles={activeCandles?.candles ?? null}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export default function Login() {
|
|||||||
localStorage.setItem('token', data.access_token);
|
localStorage.setItem('token', data.access_token);
|
||||||
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
|
localStorage.setItem('is_admin', data.is_admin ? 'true' : 'false');
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Login network error:', err);
|
||||||
setError('Network error');
|
setError('Network error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ export default function Register() {
|
|||||||
}
|
}
|
||||||
setSuccess('Account created! Redirecting...');
|
setSuccess('Account created! Redirecting...');
|
||||||
setTimeout(() => navigate('/login'), 1500);
|
setTimeout(() => navigate('/login'), 1500);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error('Register network error:', err);
|
||||||
setError('Network error');
|
setError('Network error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user