Compare commits

...

3 Commits

Author SHA1 Message Date
Gerald-H
2d19d3e753
Update README.md 2025-04-04 21:10:03 +02:00
Gerald-H
871106484e
Update README.md 2025-04-04 18:10:34 +02:00
f5f8310b38 Update 1.5.0
- Adding Portfolio Page
- Fixing Alarm Layout with Category
- Automatically delete alarms that have expired
2025-04-04 18:01:01 +02:00
19 changed files with 1584 additions and 132 deletions

View File

@ -5,8 +5,7 @@ WORKDIR /app
COPY server/package*.json ./server/
COPY server/newsfeed/package*.json ./server/newsfeed/
RUN cd server && npm install && npm install ws
RUN cd server && npm install && npm install ws && npm install node-fetch@2
RUN cd server/newsfeed && npm install
COPY . /app

View File

@ -1,4 +1,4 @@
# HodlEye Crypto Price Tracker
# HodlEye Crypto Portfolio & Price Tracker
A lightweight Docker-based web tool to monitor cryptocurrency prices (via Binance and OKX) with **unlimited alarms** and **unlimited crypto tracking**, outshining typical TradingView limitations. It also provides quick access to multiple RSS-based crypto news sources and a live Economic Calendar.
@ -25,19 +25,20 @@ Check out the live demo here: [HodlEye Demo](https://hodleye.gerald-hasani.com/)
- [Alarm Functionality](#alarm-functionality)
- [Crypto News](#crypto-news)
- [Economic Calendar](#economic-calendar)
- [TradingView Chart](#tradingview-chart)
3. [Installation & Usage](#installation--usage)
- [TradingView Chart](#tradingview-chart)
3. [Portfolio Management](#portfolio-management)
4. [Installation & Usage](#installation--usage)
- [Requirements](#requirements)
- [Environment variables (.env)](#environment-variables-env)
- [Docker Build & Run](#docker-build--run)
4. [Windows Notification App: HodlEye_Notify](#windows-notification-app-hodleye_notify)
5. [Project Structure](#project-structure)
5. [Windows Notification App: HodlEye_Notify](#windows-notification-app-hodleye_notify)
6. [Project Structure](#project-structure)
- [Frontend (index.html & magic.js)](#frontend-indexhtml--magicjs)
- [News Feed Server (Node.js)](#news-feed-server-nodejs)
6. [Important Notes / Limitations](#important-notes--limitations)
7. [Coming Soon](#coming-soon)
8. [Privacy & Data Disclaimer](#privacy--data-disclaimer)
9. [License](#license)
7. [Important Notes / Limitations](#important-notes--limitations)
8. [Coming Soon](#coming-soon)
9. [Privacy & Data Disclaimer](#privacy--data-disclaimer)
10. [License](#license)
---
@ -121,6 +122,29 @@ The tool refreshes prices every **1 seconds**, which may introduce a slight dela
<img src="https://github.com/user-attachments/assets/03ff8333-78fc-49f6-b794-f6698546ab49" width="500" height="auto">
---
## Portfolio Management
<img src="https://github.com/user-attachments/assets/afea1c01-2016-46b1-b800-bafbf6c43351" width="500" height="auto">
<img src="https://github.com/user-attachments/assets/933ea5ad-d480-4bc8-b142-5c607eea956b" width="500" height="auto">
&nbsp;
HodlEye includes robust portfolio management features to help you monitor and analyze your cryptocurrency investments:
- **Live Portfolio**:
View your active investments in real-time. This page displays essential details such as the coin symbol, amount, buy price, current price, invested amount (calculated as _amount × buy price_), profit/loss, percentage change, and buy date.
**Important:** When opening the Live Portfolio page, expect a 1-3 second delay while data is recalculated in real-time. Additionally, the live chart is refreshed every 10 seconds to ensure up-to-date pricing information.
- **Trade Summary**:
This section provides a comprehensive breakdown of your closed trades, showing the actual profits or losses realized upon selling your assets. It includes information like the coin symbol, amount, buy price, invested amount, sell price, profit, percentage profit, buy date, and sell date. A date filter is available to help you analyze trade performance over specific time ranges.
The bottom bar in the Trade Summary page displays the cumulative invested amount, overall profit/loss, and overall percentage change.
The charts in “Portfolio Live” are updated every 10 seconds
These portfolio features enable you to have a clear, up-to-date overview of both your active and completed investments, empowering you to make informed trading decisions.
---
&nbsp;
## Installation & Usage
@ -231,6 +255,7 @@ Below is an example directory tree (based on your structure). Yours may vary sli
```
HodlEye-Crypto-Price-Tracker
├── .env
├── Dockerfile
├── LICENSE.txt
├── PRIVACY.md
@ -238,6 +263,11 @@ HodlEye-Crypto-Price-Tracker
├── data
│ └── data.json
├── public
│ ├── css
│ │ ├── login.css
│ │ ├── portfolio.css
│ │ ├── responsive.css
│ │ └── style.css
│ ├── font
│ │ └── BreeSerif-Regular.ttf
│ ├── images
@ -246,31 +276,30 @@ HodlEye-Crypto-Price-Tracker
│ │ ├── favicon.png
│ │ └── github-mark.svg
│ ├── index.html
│ ├── magic.js
│ ├── news.js
│ ├── tradingview.js
│ ├── script.js
│ ├── responsive.css
│ ├── js
│ │ ├── magic.js
│ │ ├── news.js
│ │ ├── portfolio.js
│ │ ├── script.js
│ │ ├── trade_summary.js
│ │ ├── tradingview.js
│ │ └── update.js
│ ├── login.html
│ ├── portfolio.html
│ ├── sound
│ │ ├── cashing.mp3
│ │ └── ping.mp3
│ ├── style.css
│ └── update.js
│ └── trade_summary.html
├── server
│ ├── newsfeed
│ │ ├── node_modules
│ │ ├── package-lock.json
│ │ ├── package.json
│ │ └── server.js
│ ├── node_modules
│ ├── package-lock.json
│ ├── package.json
│ └── server.js
├── sound
│ ├── cashing.mp3
│ └── ping.mp3
── update.json
└── .env
── update.json
```
&nbsp;
@ -338,7 +367,6 @@ _(Within Docker, its already bundled, so just expose `5001`.)_
## Upcoming planned changes with the next versions
- **Portfolio Management**: Track your crypto holdings in real-time with easy-to-read analytics.
- **Big Movement Alarm**: Alarm function for rapid short or long events.
- **Android**: Android app with synchronization option to HodlEye Docker (First early alpha already available internally)
- **HodlEye Notify Alarm with various sound selections and HodlEye Alarms**

View File

@ -22,13 +22,13 @@ body{
border-radius: 50%;
}
.shape:first-child{
background: url('images/favicon.png') no-repeat center center;
background: url('../images/favicon.png') no-repeat center center;
background-size: contain;
left: -150px;
top: -80px;
}
.shape:last-child {
background: url('images/favicon.png') no-repeat center center;
background: url('../images/favicon.png') no-repeat center center;
background-size: contain;
right: -130px;
bottom: -80px;

207
public/css/portfolio.css Normal file
View File

@ -0,0 +1,207 @@
.button-grid-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
background: #1e1e1e;
padding: 1rem;
}
.grid-left,
.grid-middle,
.grid-right {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
body {
margin: 0;
padding: 0;
background-color: #121212;
color: #e0e0e0;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
min-width: 808px;
}
h1 {
text-align: center;
margin-top: 1rem;
padding-bottom: 20px;
}
button {
background: #333;
color: #e0e0e0;
border: none;
padding: 0.5rem 1rem;
cursor: pointer;
border-radius: 4px;
}
button:hover {
background-color: #005f73;
}
.portfolio-container {
max-width: 1100px;
margin: 1rem auto;
padding: 0 1rem;
}
.coin-section {
background-color: #1e1e1e;
border-radius: 10px;
margin-bottom: 2rem;
padding: 1rem;
}
.coin-section h2 {
margin-top: 0;
border-bottom: 1px solid #333;
padding-bottom: 0.5rem;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th, td {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid #333;
}
.summary-row {
background-color: #2b2b2b;
font-weight: bold;
}
.positive {
color: #4caf50;
}
.negative {
color: #f44336;
}
.chart-and-legend-container {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 2rem;
margin-top: 2rem;
}
#chartLegend {
min-width: 120px;
}
.legend-color-box {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 0.4rem;
vertical-align: middle;
}
.bottom-bar {
display: flex;
justify-content: center;
background: #1e1e1e;
padding: 1rem;
margin-top: 7rem;
}
.bottom-bar-item {
display: flex;
align-items: center;
margin: 0 2rem;
}
.modal {
display: none;
position: fixed;
z-index: 999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.6);
}
.modal-content {
background-color: #1e1e1e;
margin: 10% auto;
padding: 1rem;
border: 1px solid #333;
width: 80%;
max-width: 600px;
border-radius: 4px;
position: relative;
}
.modal-content h2 {
margin-top: 0;
}
.close {
position: absolute;
right: 1rem;
top: 1rem;
color: #ccc;
font-size: 1.5rem;
cursor: pointer;
}
.close:hover {
color: #fff;
}
.transaction-type-buttons {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.transaction-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.transaction-form label {
margin-top: 0.5rem;
}
.edit-buttons {
display: flex;
gap: 1rem;
margin-top: 1rem;
justify-content: flex-end;
}
.edit-button {
margin-left: 1rem;
cursor: pointer;
font-weight: bold;
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.5rem;
}
.buy-confirm-buttons {
display: flex;
justify-content: center;
margin-top: 1rem;
}
button {
padding: 10px;
margin: 5px;
font-size: 16px;
background-color: #008CBA;
color: white;
border: none;
cursor: pointer;
border-radius: 5px;
}
.price-btn {
background-color: #ff8b00;
}
.price-btn:hover {
background-color: #e38806;
}

View File

@ -27,6 +27,7 @@ body.light {
.header {
text-align: center;
padding: 20px;
max-width: 1660px;
}
.button-container {
@ -68,6 +69,7 @@ button:hover {
.main-container {
display: flex;
}
.left-column {
@ -134,13 +136,7 @@ button:hover {
padding: 10px;
}
.grid-right {
display: flex;
justify-content: flex-end;
width: 100%;
max-width: 420px;
align-self: flex-end;
}
@ -319,7 +315,7 @@ body.light .crypto-box {
top: 35%;
left: 50%;
transform: translate(-50%, -45%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
@ -458,9 +454,9 @@ body.light .api-label {
.alarm-list-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(2, 2fr);
gap: 10px;
max-height: 200px;
max-height: 300px;
overflow-y: auto;
margin: 1em 0;
}
@ -582,16 +578,6 @@ body.light .alarm-item {
}
.button-grid-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
/* alle gleich breit */
align-items: center;
justify-items: center;
column-gap: 10px;
/* oder kleiner */
margin-bottom: 15px;
}
#economicCalendarModal .iframe-container {
@ -673,7 +659,7 @@ body.light .alarm-item {
#tradingViewModal .modal-content {
min-width: 80%;
min-height: 80%;
max-height: 80%;
max-height: 80%;
text-align: center;
position: fixed;
top: 35%;
@ -697,4 +683,64 @@ body.light .alarm-item {
font-size: 24px;
cursor: pointer;
color: red;
}
.button-grid-container {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
justify-items: center;
column-gap: 10px;
align-items: start;
margin-bottom: 15px;
}
.grid-right {
display: flex;
justify-content: flex-end;
width: 100%;
align-items: start;
justify-items: stretch;
justify-content: flex-end;
}
.header-container {
max-width: 170px;
margin: 0 auto;
padding: 0 10px;
}
.alarm-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.alarm-col {
flex: 1;
}
#alarmModal .modal-content .alarm-row select,
#alarmModal .modal-content .alarm-row input {
margin-bottom: 8px;
width: 200px;
padding: 8px;
}
#alarmModal .modal-content button:last-child {
margin-top: 20px;
}

View File

@ -5,11 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HodlEye Crypto Price Tracker</title>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="responsive.css" />
<link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="css/responsive.css" />
<link rel="icon" type="image/png" href="images/favicon.png" />
</head>
<body class="dark">
<div class="header">
<h1>HodlEye Crypto Price Tracker</h1>
<div class="button-grid-container">
@ -21,12 +22,14 @@
<button onclick="openAddCryptoModal()">Add Crypto</button>
<button onclick="toggleEditMode()" id="editButton">Edit List</button>
<button class="alarm-btn" onclick="openAlarmModal()">Alarms</button>
<button class="portfolio-btn" onclick="window.location.href='portfolio.html'">Portfolio</button>
</div>
<div class="grid-right">
<button class="options-btn" onclick="openOptionsModal()">
Options
</button>
</div>
<div class="grid-right">
<button onclick="logout()">Logout</button>
</div>
</div>
@ -65,7 +68,7 @@
</button>
<div class="version-info">
<span id="currentVersion">Version 1.0.9</span>
<span id="currentVersion">Version 1.5.0</span>
<span
id="updateAvailable"
style="display: none; color: red; cursor: pointer"
@ -81,29 +84,34 @@
<div class="modal-content">
<span class="close" onclick="closeAlarmModal()">&times;</span>
<h2>Price Alarms</h2>
<div class="alarm-list-container" id="alarmListContainer"></div>
<label for="alarmSymbol">Symbol:</label>
<select id="alarmSymbol"></select
><br />
<label for="alarmPrice">Alarm Price (USDT):</label>
<input type="number" id="alarmPrice" placeholder="Price" /><br />
<label for="alarmFrequency">Frequency:</label>
<select id="alarmFrequency">
<option value="Once">Once</option>
<option value="Recurring">Recurring</option></select
><br />
<label for="alarmDirection">Direction:</label>
<select id="alarmDirection">
<option value="Rising">Rising</option>
<option value="Falling">Falling</option>
<option value="Both">Both</option></select
><br />
<div class="alarm-row">
<div class="alarm-col">
<label for="alarmSymbol">Symbol:</label>
<select id="alarmSymbol"></select>
</div>
<div class="alarm-col">
<label for="alarmPrice">Alarm Price (USDT):</label>
<input type="number" id="alarmPrice" placeholder="Price" />
</div>
</div>
<div class="alarm-row">
<div class="alarm-col">
<label for="alarmFrequency">Frequency:</label>
<select id="alarmFrequency">
<option value="Once">Once</option>
<option value="Recurring">Recurring</option>
</select>
</div>
<div class="alarm-col">
<label for="alarmDirection">Direction:</label>
<select id="alarmDirection">
<option value="Rising">Rising</option>
<option value="Falling">Falling</option>
<option value="Both">Both</option>
</select>
</div>
</div>
<button onclick="addAlarm()">Add Alarm</button>
</div>
</div>
@ -299,12 +307,13 @@
</div>
</div>
<script src="magic.js"></script>
<script src="news.js"></script>
<script src="update.js"></script>
<script src="tradingview.js"></script>
<script src="script.js"></script>
<script src="js/magic.js"></script>
<script src="js/news.js"></script>
<script src="js/update.js"></script>
<script src="js/tradingview.js"></script>
<script src="js/script.js"></script>
</body>
</html>

View File

@ -85,7 +85,10 @@ async function addAlarm() {
const direction = document.getElementById("alarmDirection").value;
if (!symbol || isNaN(price)) return;
if (alarms.some(a => a.symbol === symbol && parseFloat(a.price) === price)) {
showErrorMessage("Alarm for this symbol and price already exists.");
return;
}
try {
await fetch("/api/alarms", {
method: "POST",
@ -250,22 +253,30 @@ function renderCryptoGrid() {
function renderAlarmList() {
const container = document.getElementById("alarmListContainer");
container.innerHTML = "";
let grouped = {};
alarms.forEach((alarm) => {
const item = document.createElement("div");
item.className = "alarm-item";
const textSpan = document.createElement("span");
textSpan.innerText = `${alarm.symbol}: ${alarm.price} (${alarm.frequency}, ${alarm.direction})`;
item.appendChild(textSpan);
const delBtn = document.createElement("button");
delBtn.innerHTML = "🗑";
delBtn.className = "alarm-delete-btn";
delBtn.onclick = () => deleteAlarm(alarm.id);
item.appendChild(delBtn);
container.appendChild(item);
if (!grouped[alarm.symbol]) {
grouped[alarm.symbol] = [];
}
grouped[alarm.symbol].push(alarm);
});
Object.keys(grouped).forEach((symbol) => {
const groupHeader = document.createElement("h3");
groupHeader.textContent = symbol;
container.appendChild(groupHeader);
grouped[symbol].forEach((alarm) => {
const item = document.createElement("div");
item.className = "alarm-item";
const textSpan = document.createElement("span");
textSpan.innerText = `${alarm.price} (${alarm.frequency}, ${alarm.direction})`;
item.appendChild(textSpan);
const delBtn = document.createElement("button");
delBtn.innerHTML = "🗑";
delBtn.className = "alarm-delete-btn";
delBtn.onclick = () => deleteAlarm(alarm.id);
item.appendChild(delBtn);
container.appendChild(item);
});
});
}
@ -657,6 +668,8 @@ function checkAlarms(symbol, currentPrice) {
const msg = `⚠️ ALARM (${alarm.frequency}, ${alarm.direction}): ${symbol} reached ${alarmPrice}!`;
showAlarmPopup(msg);
if (alarm.frequency === "Once") {
deleteAlarm(alarm.id);
} else {
alarm.triggered = true;
}
}

469
public/js/portfolio.js Normal file
View File

@ -0,0 +1,469 @@
let portfolioData = [];
let coinColors = {};
let addTransactionModal, editTransactionModal, colorConfigModal;
let buyForm, sellForm;
let portfolioList, pieChartCanvas, chartLegend;
let editIdGlobal = null;
document.addEventListener("DOMContentLoaded", () => {
addTransactionModal = document.getElementById("addTransactionModal");
editTransactionModal = document.getElementById("editTransactionModal");
colorConfigModal = document.getElementById("colorConfigModal");
buyForm = document.getElementById("buyForm");
sellForm = document.getElementById("sellForm");
portfolioList = document.getElementById("portfolioList");
pieChartCanvas = document.getElementById("portfolioPieChart");
chartLegend = document.getElementById("chartLegend");
window.addEventListener("click", (event) => {
if (event.target === addTransactionModal) closeAddTransactionModal();
if (event.target === editTransactionModal) closeEditTransactionModal();
if (event.target === colorConfigModal) closeColorConfigModal();
});
loadCoinColorsFromStorage();
loadPortfolioData();
setInterval(() => {
updatePrices();
}, 10000);
});
function loadPortfolioData() {
fetch("/api/portfolio", { credentials: "include" })
.then(r => r.json())
.then(d => {
portfolioData = d.transactions || [];
updatePrices();
});
}
function updatePrices() {
fetch("/api/portfolio/prices", { credentials: "include" })
.then(r => r.json())
.then(arr => {
portfolioData = arr.map(x => {
x.id = String(x.id);
return x;
});
renderPortfolio();
drawPieChart();
});
}
function renderPortfolio() {
portfolioList.innerHTML = "";
let grouped = {};
portfolioData.forEach(t => {
if (!grouped[t.symbol]) grouped[t.symbol] = [];
grouped[t.symbol].push(t);
});
for (let sym in grouped) {
const section = document.createElement("div");
section.className = "coin-section";
const heading = document.createElement("h2");
heading.textContent = sym;
section.appendChild(heading);
const table = document.createElement("table");
const thead = document.createElement("thead");
thead.innerHTML = `
<tr>
<th>Symbol</th>
<th>Amount</th>
<th>Buy Price</th>
<th>Current</th>
<th>Invest</th>
<th>Profit / Loss</th>
<th>% Change</th>
<th>Buy Date</th>
</tr>
`;
table.appendChild(thead);
const tbody = document.createElement("tbody");
let totalAmount = 0;
let totalCost = 0;
grouped[sym].forEach(tx => {
const currentTotal = tx.amount * tx.currentPrice;
const initialTotal = tx.amount * tx.buyPrice;
const diff = currentTotal - initialTotal;
const pct = initialTotal === 0 ? 0 : (diff / initialTotal) * 100;
totalAmount += tx.amount;
totalCost += initialTotal;
const row = document.createElement("tr");
const diffClass = diff >= 0 ? "positive" : "negative";
const editButton = `<span class="edit-button" onclick="openEditTransactionModal('${tx.id}')">✎</span>`;
row.innerHTML = `
<td>${tx.symbol}</td>
<td>${tx.amount}</td>
<td>${tx.buyPrice}</td>
<td>${tx.currentPrice.toFixed(2)}</td>
<td>${(tx.amount * tx.buyPrice).toFixed(2)}</td>
<td class="${diffClass}">${diff.toFixed(2)}</td>
<td class="${diffClass}">${pct.toFixed(2)}%</td>
<td>${tx.date}${editButton}</td>
`;
tbody.appendChild(row);
});
const totalCurrent = totalAmount * (grouped[sym][0].currentPrice || 0);
const totalDiff = totalCurrent - totalCost;
const totalPct = totalCost === 0 ? 0 : (totalDiff / totalCost) * 100;
const avgBuyPrice = totalAmount === 0 ? 0 : totalCost / totalAmount;
const totalRow = document.createElement("tr");
totalRow.className = "summary-row";
const summaryClass = totalDiff >= 0 ? "positive" : "negative";
totalRow.innerHTML = `
<td>${sym} (Total)</td>
<td>${totalAmount.toFixed(6)}</td>
<td>Ø ${avgBuyPrice.toFixed(2)}</td>
<td>${(grouped[sym][0].currentPrice || 0).toFixed(2)}</td>
<td>${totalCost.toFixed(2)}</td>
<td class="${summaryClass}">${totalDiff.toFixed(2)}</td>
<td class="${summaryClass}">${totalPct.toFixed(2)}%</td>
<td>-</td>
`;
tbody.appendChild(totalRow);
table.appendChild(tbody);
section.appendChild(table);
portfolioList.appendChild(section);
}
calculateTotals();
}
function calculateTotals() {
let grandCost = 0;
let grandValue = 0;
portfolioData.forEach(t => {
grandCost += t.buyPrice * t.amount;
grandValue += t.currentPrice * t.amount;
});
const diff = grandValue - grandCost;
const pct = grandCost === 0 ? 0 : (diff / grandCost) * 100;
document.getElementById("totalInvest").textContent = grandCost.toFixed(2) + " USDT";
document.getElementById("totalProfitLoss").textContent = diff.toFixed(2) + " USDT";
document.getElementById("totalPercentChange").textContent = pct.toFixed(2) + "%";
}
function drawPieChart() {
if (!pieChartCanvas) return;
let grouped = {};
portfolioData.forEach(t => {
if (!grouped[t.symbol]) grouped[t.symbol] = 0;
grouped[t.symbol] += t.currentPrice * t.amount;
});
let totalAll = 0;
let coinValues = [];
for (let s in grouped) {
totalAll += grouped[s];
}
for (let s in grouped) {
coinValues.push({ symbol: s, value: grouped[s] });
}
const ctx = pieChartCanvas.getContext("2d");
ctx.clearRect(0, 0, pieChartCanvas.width, pieChartCanvas.height);
const centerX = pieChartCanvas.width / 2;
const centerY = pieChartCanvas.height / 2;
const radius = Math.min(centerX, centerY) - 10;
let startAngle = 0;
coinValues.forEach(item => {
const sliceAngle = totalAll === 0 ? 0 : (item.value / totalAll) * 2 * Math.PI;
if (!coinColors[item.symbol]) {
coinColors[item.symbol] = randomColor();
}
ctx.fillStyle = coinColors[item.symbol];
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fill();
startAngle += sliceAngle;
});
renderChartLegend(coinValues, totalAll);
}
function renderChartLegend(coinValues, totalAll) {
chartLegend.innerHTML = "";
coinValues.forEach(item => {
const percent = totalAll === 0 ? 0 : (item.value / totalAll) * 100;
const div = document.createElement("div");
const colorBox = document.createElement("span");
colorBox.className = "legend-color-box";
colorBox.style.backgroundColor = coinColors[item.symbol];
div.appendChild(colorBox);
div.appendChild(document.createTextNode(item.symbol + " - " + percent.toFixed(2) + "%"));
chartLegend.appendChild(div);
});
}
function openAddTransactionModal() {
addTransactionModal.style.display = "block";
showBuyForm();
}
function closeAddTransactionModal() {
addTransactionModal.style.display = "none";
}
function showBuyForm() {
buyForm.style.display = "block";
sellForm.style.display = "none";
}
function showSellForm() {
buyForm.style.display = "none";
sellForm.style.display = "block";
populateSellDropdown();
}
function confirmBuy() {
const symbol = document.getElementById("buySymbol").value.trim().toUpperCase();
const amount = parseFloat(document.getElementById("buyAmount").value);
const buyPrice = parseFloat(document.getElementById("buyPrice").value);
const buyDate = document.getElementById("buyDate").value;
if (!symbol || isNaN(amount) || isNaN(buyPrice)) {
alert("Bitte gültige Werte eingeben");
return;
}
const newId = Date.now() + Math.floor(Math.random() * 999999);
fetch("/api/coinPrice?symbol=" + symbol, { credentials: "include" })
.then(r => r.json())
.then(j => {
if (j.error) {
alert("Dieser Coin wird nicht unterstützt");
} else {
fetch("/api/portfolio/buy", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: newId,
symbol,
amount,
buyPrice,
date: buyDate
}),
credentials: "include"
})
.then(rr => rr.json())
.then(result => {
if (result.error) {
alert(result.error);
} else {
updatePrices();
closeAddTransactionModal();
}
});
}
})
.catch(() => alert("Netzwerkfehler"));
}
function confirmSell() {
const sel = document.getElementById("sellSelectTransaction");
const val = sel.value;
if (!val) return;
const sellAmount = parseFloat(document.getElementById("sellAmount").value);
const sellPrice = parseFloat(document.getElementById("sellPrice").value);
const sellDate = document.getElementById("sellDate").value;
if (isNaN(sellAmount) || isNaN(sellPrice) || !sellDate) {
alert("Bitte gültige Werte eingeben");
return;
}
const parts = val.split("|");
const sym = parts[0];
const idx = parseInt(parts[1], 10);
let selectedTransaction = null;
let count = 0;
for (let i = 0; i < portfolioData.length; i++) {
if (portfolioData[i].symbol === sym) {
if (count === idx) {
selectedTransaction = portfolioData[i];
break;
}
count++;
}
}
if (!selectedTransaction) {
alert("Ungültig");
return;
}
fetch("/api/portfolio/sell", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: selectedTransaction.id,
sellAmount: sellAmount,
sellPrice: sellPrice,
sellDate: sellDate
}),
credentials: "include"
})
.then(r => r.json())
.then(j => {
if (j.error) {
alert(j.error);
} else {
updatePrices();
closeAddTransactionModal();
}
})
.catch(() => alert("Fehler beim Sell"));
}
function populateSellDropdown() {
const sel = document.getElementById("sellSelectTransaction");
sel.innerHTML = "";
let grouped = {};
portfolioData.forEach(t => {
if (!grouped[t.symbol]) grouped[t.symbol] = [];
grouped[t.symbol].push(t);
});
for (let sym in grouped) {
grouped[sym].forEach((tx, i) => {
const opt = document.createElement("option");
opt.value = sym + "|" + i;
opt.text = sym + " - " + tx.amount + "@" + tx.buyPrice + " (" + tx.date + ")";
sel.appendChild(opt);
});
}
}
function openEditTransactionModal(id) {
editTransactionModal.style.display = "block";
editIdGlobal = id;
let t = portfolioData.find(x => String(x.id) === String(id));
if (!t) {
alert("Keine gültige Transaktion gefunden");
return;
}
document.getElementById("editSymbol").value = t.symbol;
document.getElementById("editAmount").value = t.amount;
document.getElementById("editBuyPrice").value = t.buyPrice;
document.getElementById("editDate").value = t.date;
}
function closeEditTransactionModal() {
editTransactionModal.style.display = "none";
}
function saveEditedTransaction() {
let t = portfolioData.find(x => String(x.id) === String(editIdGlobal));
if (!t) {
alert("Keine gültige Transaktion gefunden");
return;
}
const newAmount = parseFloat(document.getElementById("editAmount").value);
const newBuyPrice = parseFloat(document.getElementById("editBuyPrice").value);
const newDate = document.getElementById("editDate").value;
if (isNaN(newAmount) || isNaN(newBuyPrice)) {
alert("Bitte gültige Werte eingeben");
return;
}
fetch("/api/portfolio/edit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: t.id,
symbol: t.symbol,
newAmount,
newBuyPrice,
newDate
}),
credentials: "include"
})
.then(r => r.json())
.then(j => {
if (j.error) {
alert(j.error);
} else {
closeEditTransactionModal();
updatePrices();
}
})
.catch(() => alert("Fehler bei Edit"));
}
function deleteTransaction() {
let t = portfolioData.find(x => String(x.id) === String(editIdGlobal));
if (!t) {
alert("Keine gültige Transaktion gefunden");
return;
}
fetch("/api/portfolio/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: t.id, symbol: t.symbol }),
credentials: "include"
})
.then(r => r.json())
.then(j => {
if (j.error) {
alert(j.error);
} else {
closeEditTransactionModal();
updatePrices();
}
})
.catch(() => alert("Fehler bei Delete"));
}
function openColorConfigModal() {
colorConfigModal.style.display = "block";
renderColorConfigList();
}
function closeColorConfigModal() {
colorConfigModal.style.display = "none";
}
function renderColorConfigList() {
const container = document.getElementById("colorConfigList");
container.innerHTML = "";
let syms = {};
portfolioData.forEach(t => { syms[t.symbol] = true; });
Object.keys(syms).forEach(s => {
const div = document.createElement("div");
div.style.marginBottom = "0.5rem";
const label = document.createElement("label");
label.textContent = s + ": ";
div.appendChild(label);
const input = document.createElement("input");
input.type = "color";
if (!coinColors[s]) {
coinColors[s] = randomColor();
}
input.value = rgbToHex(coinColors[s]);
input.addEventListener("input", () => {
coinColors[s] = input.value;
});
div.appendChild(input);
container.appendChild(div);
});
}
function saveColorConfig() {
closeColorConfigModal();
saveCoinColorsToStorage();
drawPieChart();
}
function randomColor() {
const r = Math.floor(Math.random() * 200 + 55);
const g = Math.floor(Math.random() * 200 + 55);
const b = Math.floor(Math.random() * 200 + 55);
return `rgb(${r},${g},${b})`;
}
function rgbToHex(str) {
if (str.indexOf("#") === 0) return str;
const p = str.replace(/[^\d,]/g, "").split(",");
const r = parseInt(p[0], 10);
const g = parseInt(p[1], 10);
const b = parseInt(p[2], 10);
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
function loadCoinColorsFromStorage() {
const s = localStorage.getItem("coinColors");
if (s) coinColors = JSON.parse(s);
}
function saveCoinColorsToStorage() {
localStorage.setItem("coinColors", JSON.stringify(coinColors));
}

180
public/js/trade_summary.js Normal file
View File

@ -0,0 +1,180 @@
let allTrades = [];
document.addEventListener("DOMContentLoaded", () => {
loadTradeSummary();
});
function loadTradeSummary() {
fetch("/api/trade_summary", { credentials: "include" })
.then(r => r.json())
.then(trades => {
allTrades = trades;
renderTradeSummary(allTrades);
updateBottomBar(allTrades);
})
.catch(err => console.error("Fehler beim Laden der Trade Summary", err));
}
function renderTradeSummary(trades) {
const container = document.getElementById("tradeSummaryContainer");
container.innerHTML = "";
let grouped = {};
trades.forEach(trade => {
if (!grouped[trade.symbol]) {
grouped[trade.symbol] = [];
}
grouped[trade.symbol].push(trade);
});
for (let symbol in grouped) {
let section = document.createElement("div");
section.className = "coin-section";
let heading = document.createElement("h2");
heading.textContent = symbol;
section.appendChild(heading);
let table = document.createElement("table");
let thead = document.createElement("thead");
thead.innerHTML = `
<tr>
<th>Symbol</th>
<th>Amount</th>
<th>Buy Price</th>
<th>Invest</th>
<th>Sell Price</th>
<th>Profit</th>
<th>% Profit</th>
<th>Buy Date</th>
<th>Sell Date</th>
</tr>
`;
table.appendChild(thead);
let tbody = document.createElement("tbody");
grouped[symbol].sort((a, b) => new Date(b.sellDate) - new Date(a.sellDate));
let totalProfit = 0;
let totalBuyCost = 0;
let totalSellValue = 0;
let totalAmount = 0;
grouped[symbol].forEach(trade => {
let row = document.createElement("tr");
let profitClass = trade.profit >= 0 ? "positive" : "negative";
let invest = trade.buyPrice * trade.amount;
row.innerHTML = `
<td>${trade.symbol}</td>
<td>${trade.amount}</td>
<td>${trade.buyPrice}</td>
<td>${invest.toFixed(2)}</td>
<td>${trade.sellPrice}</td>
<td class="${profitClass}">${trade.profit.toFixed(2)}</td>
<td class="${profitClass}">${trade.percentProfit.toFixed(2)}%</td>
<td>${trade.buyDate}</td>
<td>${trade.sellDate}</td>
`;
tbody.appendChild(row);
totalProfit += trade.profit;
totalAmount += trade.amount;
totalBuyCost += invest;
totalSellValue += trade.sellPrice * trade.amount;
});
let totalPctProfit = 0;
if (totalBuyCost > 0) {
totalPctProfit = ((totalSellValue - totalBuyCost) / totalBuyCost) * 100;
}
let summaryRow = document.createElement("tr");
summaryRow.className = "summary-row";
let summaryProfitClass = totalProfit >= 0 ? "positive" : "negative";
summaryRow.innerHTML = `
<td>${symbol} (Total)</td>
<td>${totalAmount}</td>
<td>-</td>
<td>${totalBuyCost.toFixed(2)}</td>
<td>-</td>
<td class="${summaryProfitClass}">${totalProfit.toFixed(2)}</td>
<td class="${summaryProfitClass}">${totalPctProfit.toFixed(2)}%</td>
<td>-</td>
<td>-</td>
`;
tbody.appendChild(summaryRow);
table.appendChild(tbody);
section.appendChild(table);
container.appendChild(section);
}
}
function updateBottomBar(trades) {
let totalProfit = 0;
let totalBuyCost = 0;
let totalSellValue = 0;
trades.forEach(tr => {
totalProfit += tr.profit;
totalBuyCost += tr.buyPrice * tr.amount;
totalSellValue += tr.sellPrice * tr.amount;
});
let pctChange = 0;
if (totalBuyCost > 0) {
pctChange = ((totalSellValue - totalBuyCost) / totalBuyCost) * 100;
}
document.getElementById("totalTradeInvest").textContent = totalBuyCost.toFixed(2) + " USDT";
document.getElementById("totalTradeProfit").textContent = totalProfit.toFixed(2) + " USDT";
document.getElementById("totalTradePercentChange").textContent = pctChange.toFixed(2) + "%";
}
function openDateFilterModal() {
document.getElementById("dateFilterModal").style.display = "block";
}
function closeDateFilterModal() {
document.getElementById("dateFilterModal").style.display = "none";
}
function applyDateFilter() {
const radios = document.getElementsByName("dateFilterType");
let filterType = "all";
for (let r of radios) {
if (r.checked) {
filterType = r.value;
break;
}
}
if (filterType === "all") {
renderTradeSummary(allTrades);
updateBottomBar(allTrades);
closeDateFilterModal();
return;
}
const fromDateValue = document.getElementById("filterFromDate").value;
const toDateValue = document.getElementById("filterToDate").value;
let filtered = allTrades.filter(tr => {
let sd = new Date(tr.sellDate);
if (fromDateValue) {
let from = new Date(fromDateValue);
if (sd < from) return false;
}
if (toDateValue) {
let to = new Date(toDateValue);
to.setHours(23,59,59,999);
if (sd > to) return false;
}
return true;
});
renderTradeSummary(filtered);
updateBottomBar(filtered);
closeDateFilterModal();
}

View File

@ -1,4 +1,4 @@
const CURRENT_VERSION = "1.0.9";
const CURRENT_VERSION = "1.5.0";
function getUpdateUrl() {
return "/api/update?t=" + new Date().getTime();

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<title>Login</title>
<link rel="stylesheet" href="login.css">
<link rel="stylesheet" href="css/login.css">
</head>
<body>
<div class="background">

149
public/portfolio.html Normal file
View File

@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Crypto Portfolio Tracker</title>
<link rel="stylesheet" href="css/portfolio.css" />
<script defer src="js/portfolio.js"></script>
<link rel="icon" type="image/png" href="images/favicon.png" />
<title>Portfolio</title>
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.page-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content {
flex: 1;
}
</style>
</head>
<body>
<div class="page-container">
<div class="content">
<div class="button-grid-container">
<div class="grid-left">
<button class="price-btn" onclick="window.location.href='index.html'">Price Tracker</button>
</div>
<div class="grid-middle">
<button>Live Portfolio</button>
<button onclick="openAddTransactionModal()">Add Transaction</button>
<button onclick="window.location.href='trade_summary.html'">Trade Summary</button>
</div>
<div class="grid-right">
<button onclick="window.location.href='/logout'">Logout</button>
</div>
</div>
<h1>Crypto Portfolio</h1>
<div class="portfolio-container" id="portfolioList"></div>
<div class="chart-and-legend-container">
<canvas id="portfolioPieChart" width="400" height="400" onclick="openColorConfigModal()"></canvas>
<div id="chartLegend"></div>
</div>
</div>
<div class="bottom-bar">
<div class="bottom-bar-item">
Invest: <span id="totalInvest">0 USDT</span>
</div>
<div class="bottom-bar-item">
Total Profit/Loss: <span id="totalProfitLoss">0 USDT</span>
</div>
<div class="bottom-bar-item">
% Change: <span id="totalPercentChange">0%</span>
</div>
</div>
</div>
<div id="addTransactionModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeAddTransactionModal()">&times;</span>
<h2>Add Transaction</h2>
<div class="transaction-type-buttons">
<button onclick="showBuyForm()">Buy</button>
<button onclick="showSellForm()">Sell</button>
</div>
<div id="buyForm" class="transaction-form" style="display: none;">
<div class="form-row">
<label for="buySymbol">Symbol:</label>
<input type="text" id="buySymbol" placeholder="BTC" />
</div>
<div class="form-row">
<label for="buyAmount">Amount:</label>
<input type="number" step="0.000001" id="buyAmount" placeholder="0.4" />
</div>
<div class="form-row">
<label for="buyPrice">Buy Price (USDT):</label>
<input type="number" step="0.01" id="buyPrice" placeholder="30000" />
</div>
<div class="form-row">
<label for="buyDate">Buy Date:</label>
<input type="date" id="buyDate" />
</div>
<div class="buy-confirm-buttons">
<button onclick="confirmBuy()">Confirm purchase</button>
</div>
</div>
<div id="sellForm" class="transaction-form" style="display: none;">
<div class="form-row">
<label for="sellSelectTransaction">Existing transaction:</label>
<select id="sellSelectTransaction"></select>
</div>
<div class="form-row">
<label for="sellAmount">Amount:</label>
<input type="number" step="0.000001" id="sellAmount" placeholder="0.2" />
</div>
<div class="form-row">
<label for="sellPrice">Sell Price (USDT):</label>
<input type="number" step="0.01" id="sellPrice" placeholder="32000" />
</div>
<div class="form-row">
<label for="sellDate">Sell Date:</label>
<input type="date" id="sellDate" />
</div>
<div class="buy-confirm-buttons">
<button onclick="confirmSell()">Confirm sale</button>
</div>
</div>
</div>
</div>
<div id="editTransactionModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeEditTransactionModal()">&times;</span>
<h2>Edit / Delete Transaction</h2>
<label for="editSymbol">Symbol:</label>
<input type="text" id="editSymbol" disabled />
<label for="editAmount">Amount:</label>
<input type="number" step="0.000001" id="editAmount" />
<label for="editBuyPrice">Buy Price (USDT):</label>
<input type="number" step="0.01" id="editBuyPrice" />
<label for="editDate">Date:</label>
<input type="date" id="editDate" />
<div class="edit-buttons">
<button onclick="saveEditedTransaction()">Save</button>
<button onclick="deleteTransaction()">Delete</button>
</div>
</div>
</div>
<div id="colorConfigModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeColorConfigModal()">&times;</span>
<h2>Diagram colors</h2>
<div id="colorConfigList"></div>
<button onclick="saveColorConfig()">OK</button>
</div>
</div>
</body>
</html>

86
public/trade_summary.html Normal file
View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="icon" type="image/png" href="images/favicon.png" />
<title>Portfolio: Trade Summary</title>
<link rel="stylesheet" href="css/portfolio.css" />
<script defer src="js/trade_summary.js"></script>
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
}
.page-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.content {
flex: 1;
}
</style>
</head>
<body>
<div class="page-container">
<div class="content">
<div class="button-grid-container">
<div class="grid-left">
<button class="price-btn" onclick="window.location.href='index.html'">Price Tracker</button>
</div>
<div class="grid-middle">
<button onclick="window.location.href='portfolio.html'">Live Portfolio</button>
<button>Trade Summary</button>
<button onclick="window.location.href='portfolio.html'">Add Transaction</button>
</div>
<div class="grid-right">
<button onclick="window.location.href='/logout'">Logout</button>
</div>
</div>
<h1>Trade Summary</h1>
<div id="tradeSummaryContainer" class="portfolio-container"></div>
</div>
<div class="bottom-bar">
<div class="bottom-bar-item">
Invest: <span id="totalTradeInvest">0 USDT</span>
</div>
<div class="bottom-bar-item">
Total Profit/Loss: <span id="totalTradeProfit">0 USDT</span>
</div>
<div class="bottom-bar-item">
% Change: <span id="totalTradePercentChange">0%</span>
</div>
<div class="bottom-bar-item">
<button onclick="openDateFilterModal()">Date</button>
</div>
</div>
</div>
<div id="dateFilterModal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeDateFilterModal()">&times;</span>
<h2>Filter time period</h2>
<div style="margin-top: 1rem;">
<label>
<input type="radio" name="dateFilterType" value="all" checked>
Show all
</label>
</div>
<div style="margin-top: 1rem;">
<label>
<input type="radio" name="dateFilterType" value="range">
Von <input type="date" id="filterFromDate">
Bis <input type="date" id="filterToDate">
</label>
</div>
<div style="margin-top: 1rem;">
<button onclick="applyDateFilter()">Apply filter</button>
</div>
</div>
</div>
</body>
</html>

View File

@ -6,6 +6,16 @@ const http = require("http");
const WebSocket = require("ws");
const session = require("express-session");
require("dotenv").config();
const fetch = require("node-fetch");
/*
* Metadata
* Version: 1.5.0
* Author/Dev: Gerald Hasani
* Name: HodlEye Crypto Price Tracker
* Email: contact@gerald-hasani.com
* GitHub: https://github.com/Gerald-Ha
*/
const app = express();
app.use(cors());
@ -50,33 +60,21 @@ app.get("/logout", (req, res) => {
const server = http.createServer(app);
const wss = new WebSocket.Server({ server });
const clients = new Set();
/*
* Metadata
* Version: 1.0.6
* Author/Dev: Gerald Hasani
* Name: HodlEye Crypto Price Tracker
* Email: contact@gerald-hasani.com
* GitHub: https://github.com/Gerald-Ha
*/
const DATA_FILE = path.join(__dirname, "..", "data", "data.json");
if (!fs.existsSync(DATA_FILE)) {
fs.writeFileSync(
DATA_FILE,
JSON.stringify(
{
cryptos: ["BTC"],
alarms: [],
notifications: []
},
null,
2
)
);
fs.writeFileSync(DATA_FILE, JSON.stringify({ cryptos: ["BTC"], alarms: [], notifications: [] }, null, 2));
}
const PORTFOLIO_FILE = path.join(__dirname, "..", "data", "portfolio.json");
if (!fs.existsSync(PORTFOLIO_FILE)) {
fs.writeFileSync(PORTFOLIO_FILE, JSON.stringify({ transactions: [] }, null, 2));
}
const TRADE_SUMMARY_FILE = path.join(__dirname, "..", "data", "trade_summary.json");
if (!fs.existsSync(TRADE_SUMMARY_FILE)) {
fs.writeFileSync(TRADE_SUMMARY_FILE, JSON.stringify({ trades: [] }, null, 2));
}
function readData() {
@ -87,6 +85,51 @@ function writeData(data) {
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
}
function readPortfolio() {
return JSON.parse(fs.readFileSync(PORTFOLIO_FILE, "utf8"));
}
function writePortfolio(data) {
fs.writeFileSync(PORTFOLIO_FILE, JSON.stringify(data, null, 2));
}
function readTradeSummary() {
return JSON.parse(fs.readFileSync(TRADE_SUMMARY_FILE, "utf8"));
}
function writeTradeSummary(data) {
fs.writeFileSync(TRADE_SUMMARY_FILE, JSON.stringify(data, null, 2));
}
const fsp = fs.promises;
async function readDataAsync() {
const content = await fsp.readFile(DATA_FILE, "utf8");
return JSON.parse(content);
}
async function writeDataAsync(data) {
await fsp.writeFile(DATA_FILE, JSON.stringify(data, null, 2));
}
async function readPortfolioAsync() {
const content = await fsp.readFile(PORTFOLIO_FILE, "utf8");
return JSON.parse(content);
}
async function writePortfolioAsync(data) {
await fsp.writeFile(PORTFOLIO_FILE, JSON.stringify(data, null, 2));
}
async function readTradeSummaryAsync() {
const content = await fsp.readFile(TRADE_SUMMARY_FILE, "utf8");
return JSON.parse(content);
}
async function writeTradeSummaryAsync(data) {
await fsp.writeFile(TRADE_SUMMARY_FILE, JSON.stringify(data, null, 2));
}
app.get("/api/update", (req, res) => {
const remoteUpdateUrl = "https://raw.githubusercontent.com/Gerald-Ha/HodlEye-Crypto-Price-Tracker/refs/heads/main/update.json";
fetch(remoteUpdateUrl)
@ -97,8 +140,7 @@ app.get("/api/update", (req, res) => {
res.header("Expires", "0");
res.json(data);
})
.catch(error => {
console.error("Fehler beim Abrufen der remote update.json:", error);
.catch(() => {
res.status(500).json({ error: "Could not fetch update data" });
});
});
@ -205,18 +247,241 @@ app.delete("/api/notifications", (req, res) => {
res.json({ success: true });
});
async function binanceSupported(sym) {
const url = `https://api.binance.com/api/v3/ticker/24hr?symbol=${sym}USDT`;
try {
const r = await fetch(url);
if (!r.ok) return false;
const j = await r.json();
if (j.code) return false;
return true;
} catch {
return false;
}
}
async function okxSupported(sym) {
const url = `https://www.okx.com/api/v5/market/ticker?instId=${sym}-USDT`;
try {
const r = await fetch(url);
if (!r.ok) return false;
const j = await r.json();
if (!j.data || !j.data[0]) return false;
return true;
} catch {
return false;
}
}
async function getBinancePrice(sym) {
const tUrl = `https://api.binance.com/api/v3/ticker/24hr?symbol=${sym}USDT`;
const r = await fetch(tUrl);
if (!r.ok) throw new Error("Binance request failed");
const d = await r.json();
if (d.code) throw new Error("Binance error code");
return parseFloat(d.lastPrice);
}
async function getOkxPrice(sym) {
const url = `https://www.okx.com/api/v5/market/ticker?instId=${sym}-USDT`;
const r = await fetch(url);
if (!r.ok) throw new Error("OKX request failed");
const j = await r.json();
if (!j.data || !j.data[0]) throw new Error("No OKX data");
return parseFloat(j.data[0].last);
}
app.get("/api/coinPrice", async (req, res) => {
const sym = req.query.symbol ? req.query.symbol.toUpperCase() : "";
if (!sym) return res.status(400).json({ error: "No symbol" });
let price;
const binSup = await binanceSupported(sym);
const okxSup = await okxSupported(sym);
if (!binSup && !okxSup) {
return res.status(404).json({ error: "Not supported" });
}
if (binSup) {
try {
price = await getBinancePrice(sym);
return res.json({ price });
} catch {
if (!okxSup) return res.status(500).json({ error: "Failed fetching price" });
}
}
if (!price && okxSup) {
try {
price = await getOkxPrice(sym);
return res.json({ price });
} catch {
return res.status(500).json({ error: "Failed fetching price" });
}
}
});
app.get("/api/portfolio", (req, res) => {
const data = readPortfolio();
res.json(data);
});
app.post("/api/portfolio/buy", async (req, res) => {
const { id, symbol, amount, buyPrice, date } = req.body;
if (!symbol || !amount || !buyPrice) {
return res.status(400).json({ error: "Invalid data" });
}
const upSym = symbol.toUpperCase();
const binSup = await binanceSupported(upSym);
const okxSup = await okxSupported(upSym);
if (!binSup && !okxSup) {
return res.status(400).json({ error: "Coin not supported" });
}
let realPrice;
if (binSup) {
try {
realPrice = await getBinancePrice(upSym);
} catch {
realPrice = parseFloat(buyPrice);
}
} else if (okxSup) {
try {
realPrice = await getOkxPrice(upSym);
} catch {
realPrice = parseFloat(buyPrice);
}
} else {
realPrice = parseFloat(buyPrice);
}
const pf = readPortfolio();
const transactionId = id ? String(id) : String(Date.now() + Math.floor(Math.random() * 999999));
pf.transactions.push({
id: transactionId,
symbol: upSym,
amount: parseFloat(amount),
buyPrice: parseFloat(buyPrice),
date: date || new Date().toISOString().split("T")[0],
currentPrice: realPrice
});
writePortfolio(pf);
res.json({ success: true });
});
app.get("/api/portfolio/prices", async (req, res) => {
const pf = readPortfolio();
let result = [];
let changed = false;
for (let t of pf.transactions) {
if (!t.id) {
t.id = String(Date.now() + Math.floor(Math.random() * 999999));
changed = true;
} else {
t.id = String(t.id);
}
let price = t.currentPrice;
try {
const binSup = await binanceSupported(t.symbol);
const okxSup = await okxSupported(t.symbol);
if (binSup) {
price = await getBinancePrice(t.symbol);
} else if (okxSup) {
price = await getOkxPrice(t.symbol);
}
t.currentPrice = price;
} catch {}
result.push({
id: t.id,
symbol: t.symbol,
amount: t.amount,
buyPrice: t.buyPrice,
date: t.date,
currentPrice: t.currentPrice
});
}
if (changed) {
writePortfolio(pf);
}
res.json(result);
});
app.post("/api/portfolio/edit", (req, res) => {
const { id, newAmount, newBuyPrice, newDate } = req.body;
const pf = readPortfolio();
let tx = pf.transactions.find(x => String(x.id) === String(id));
if (!tx) {
return res.status(400).json({ error: "Invalid ID" });
}
tx.amount = parseFloat(newAmount);
tx.buyPrice = parseFloat(newBuyPrice);
tx.date = newDate;
writePortfolio(pf);
res.json({ success: true });
});
app.post("/api/portfolio/delete", (req, res) => {
const { id } = req.body;
const pf = readPortfolio();
const before = pf.transactions.length;
pf.transactions = pf.transactions.filter(t => String(t.id) !== String(id));
if (pf.transactions.length === before) {
return res.status(400).json({ error: "No transaction with given ID" });
}
writePortfolio(pf);
res.json({ success: true });
});
app.post("/api/portfolio/sell", (req, res) => {
const { id, sellAmount, sellPrice, sellDate } = req.body;
if (!id || isNaN(sellAmount) || isNaN(sellPrice) || !sellDate) {
return res.status(400).json({ error: "Ungültige Daten" });
}
const pf = readPortfolio();
let txIndex = pf.transactions.findIndex(x => String(x.id) === String(id));
if (txIndex === -1) {
return res.status(400).json({ error: "Transaktion nicht gefunden" });
}
let tx = pf.transactions[txIndex];
if (sellAmount > tx.amount) {
return res.status(400).json({ error: "Verkaufsmenge überschreitet die vorhandene Menge" });
}
const profit = (parseFloat(sellPrice) - parseFloat(tx.buyPrice)) * sellAmount;
const percentProfit = tx.buyPrice > 0 ? (profit / (tx.buyPrice * sellAmount)) * 100 : 0;
const tradeEntry = {
id: Date.now(),
symbol: tx.symbol,
amount: sellAmount,
buyPrice: tx.buyPrice,
sellPrice: parseFloat(sellPrice),
profit: profit,
percentProfit: percentProfit,
buyDate: tx.date,
sellDate: sellDate
};
if (sellAmount < tx.amount) {
tx.amount = tx.amount - sellAmount;
} else {
pf.transactions.splice(txIndex, 1);
}
writePortfolio(pf);
const tsData = readTradeSummary();
tsData.trades.push(tradeEntry);
writeTradeSummary(tsData);
res.json({ success: true, trade: tradeEntry });
});
app.get("/api/trade_summary", (req, res) => {
const data = readTradeSummary();
data.trades.sort((a, b) => new Date(b.sellDate) - new Date(a.sellDate));
res.json(data.trades);
});
app.use(express.static(path.join(__dirname, "..", "public")));
wss.on("connection", (ws) => {
console.log("Client verbunden");
clients.add(ws);
ws.on("close", () => {
console.log("Client getrennt");
clients.delete(ws);
});
});
const PORT = process.env.PORT || 3099;
server.listen(PORT, () => {
console.log(`Server läuft auf Port ${PORT}`);
console.log("Server läuft auf Port " + PORT);
});

View File

@ -1,7 +1,8 @@
{
"version": "1.0.9",
"version": "1.5.0",
"changelog": [
"Bug fixes and improvements",
"Adding Login Display"
"Adding Portfolio Page",
"Fixing Alarm Layout with Category",
"Automatically delete alarms that have expired"
]
}