value averging model bulild, can use yahoo and csv fron other sources

This commit is contained in:
2026-01-28 08:48:53 +08:00
parent 9e7f474d5e
commit cf708d2466
12 changed files with 38342 additions and 27 deletions
Binary file not shown.
+54 -6
View File
@@ -1,6 +1,6 @@
from flask import Flask, render_template, jsonify
from flask import Flask, render_template,request, jsonify
from datetime import datetime, timedelta
from engine import DataEngine
from engine import DataEngine, StrategyEngine
import concurrent.futures
from flask_caching import Cache
import csv, os, logging
@@ -9,10 +9,6 @@ from concurrent.futures import ThreadPoolExecutor
app = Flask(__name__)
cache = Cache(app, config={'CACHE_TYPE': 'SimpleCache'})
import os
import csv
@cache.memoize(timeout=3600)
def fetch_and_calculate(config):
engine = DataEngine(config['symbol'], config['url'], config['provider'])
@@ -63,5 +59,57 @@ def run_sync():
report = engine.global_sync()
return jsonify(report)
@app.route('/api/backtest', methods=['POST'])
def api_backtest():
data = request.get_json() or {}
# 1. Extract and Sanitize Symbol
symbol = data.get('symbol', '').strip().upper()
print(f"DEBUG: Processing {symbol}")
if not symbol:
return jsonify({"error": "Symbol is required"}), 400
try:
# 2. Extract Numerical and Logic Inputs (Matching updated JS keys)
initial = float(data.get('initial_inv', 0))
monthly = float(data.get('monthly_target', 0))
start_date = data.get('start_date', '2024-01-01')
# Robust Boolean Check for toggles
allow_sell = data.get('allow_sell') is True
allow_frac = data.get('allow_fractional') is True
# 3. Initialize Engines
# DataEngine __init__ now calls ensure_data() to download if missing
data_eng = DataEngine(symbol=symbol)
if not os.path.exists(data_eng.file_path):
return jsonify({"error": f"Data for {symbol} could not be retrieved."}), 404
strat_eng = StrategyEngine(data_eng)
# 4. Calculation
history = strat_eng.calculate_va_vs_dca(
initial_inv=initial,
monthly_target=monthly,
start_date=start_date,
allow_sell=allow_sell,
allow_fractional=allow_frac
)
return jsonify(history)
except Exception as e:
import traceback
print("CRITICAL ERROR in /api/backtest:")
print(traceback.format_exc())
return jsonify({"error": "Internal Server Error. Check terminal logs."}), 500
@app.route('/backtest') # This is the URL you will actually visit
def backtest_ui():
# This sends the HTML file to your browser
return render_template('val_avg_cal.html')
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5000)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5936
View File
File diff suppressed because it is too large Load Diff
+2589
View File
File diff suppressed because it is too large Load Diff
+8306
View File
File diff suppressed because it is too large Load Diff
+3870
View File
File diff suppressed because it is too large Load Diff
+241 -18
View File
@@ -1,56 +1,107 @@
import pandas as pd
import requests
import os
import csv
import shutil
from datetime import datetime, time
import yfinance as yf
from ta.trend import EMAIndicator
from ta.momentum import StochasticOscillator
import math
class DataEngine:
def __init__(self, symbol=None, url=None, provider=None, data_dir='data_cache'):
self.symbol = symbol
self.url = url
self.provider = provider
# 1. Clean the incoming symbol
self.symbol = symbol.strip().upper() if symbol else None
# Use your robust path logic
# 2. Setup centralized paths
base_path = os.path.dirname(os.path.abspath(__file__))
self.cache_dir = os.path.join(base_path, data_dir) # Use data_dir variable
self.cache_dir = os.path.join(base_path, data_dir)
os.makedirs(self.cache_dir, exist_ok=True)
# 4. Only set file_path if we actually have a symbol
# 3. Load the master instrument list to find URLs/Providers
# This ensures the engine knows where to go for special tickers
self.master_instruments = self.load_instruments_from_csv('instruments.csv')
# 4. Find config from master list or use passed-in arguments
instrument_config = next((i for i in self.master_instruments if i['symbol'] == self.symbol), None)
if instrument_config:
self.url = instrument_config['url']
self.provider = instrument_config['provider']
else:
# Fallback to arguments if ticker isn't in the CSV list
self.url = url
self.provider = provider or 'yahoo'
# 5. Define final file path for centralized storage
if self.symbol:
self.file_path = os.path.join(self.cache_dir, f"{self.symbol}.csv")
else:
self.file_path = None
self.ensure_data()
def ensure_data(self):
"""Checks if file exists; if not, downloads it."""
if os.path.exists(self.file_path):
return True # Data is already there
print(f"DEBUG: {self.symbol} not found in cache. Attempting download...")
try:
# For a generic ticker like SPY, we use yfinance
import yfinance as yf
df = yf.download(self.symbol, period="max")
if df.empty:
print(f"ERROR: No data found for {self.symbol}")
return False
# Clean and save
# 1. If columns are MultiIndex (tuples), take just the first level (the price name)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
# 2. Reset index to turn 'Date' into a column
df.reset_index(inplace=True)
# 3. Now it is safe to lowercase the column names
df.columns = [str(c).lower() for c in df.columns]
df.to_csv(self.file_path, index=False)
print(f"DEBUG: Successfully cached {self.symbol}")
return True
except Exception as e:
print(f"ERROR: Download failed for {self.symbol}: {e}")
return False
def load_instruments_from_csv(self, file_path):
import csv
instruments = []
# Updated templates for maximum historical reach
TEMPLATES = {
'jpm': "https://am.jpmorgan.com/FundsMarketingHandler/historicalData?cusip={cusip}&country=hk&role=per",
# period1=0 fetches from the earliest available date; interval=1d is daily
'yahoo': "https://query1.finance.yahoo.com/v8/finance/chart/{cusip}?period1=0&period2=9999999999&interval=1d&events=history",
# FT remains 30-day window; Smart Append logic in fetch_data handles the history
'agi': "https://markets.ft.com/data/funds/tearsheet/historical?s={cusip}"
}
try:
abs_path = os.path.join(os.path.dirname(__file__), file_path)
# Get absolute path relative to this script
abs_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), file_path)
if not os.path.exists(abs_path):
print(f"Error: {file_path} not found.")
print(f"Error: Master list {file_path} not found at {abs_path}")
return []
with open(abs_path, mode='r', encoding='utf-8-sig') as csvfile:
reader = csv.DictReader(csvfile)
# Clean header names (lowercase + remove whitespace)
reader.fieldnames = [name.strip().lower() for name in reader.fieldnames]
for row in reader:
symbol = row.get('symbol', '').strip()
cusip = row.get('cusip', '').strip()
provider = row.get('provider', 'jpm').strip().lower()
# Use .get() with fallback to avoid KeyErrors
symbol = (row.get('symbol') or '').strip().upper()
cusip = (row.get('cusip') or '').strip()
provider = (row.get('provider') or 'jpm').strip().lower()
if symbol and cusip:
template = TEMPLATES.get(provider, TEMPLATES['jpm'])
@@ -60,13 +111,36 @@ class DataEngine:
"symbol": symbol,
"url": url,
"provider": provider,
"cusip": cusip # Added this so sync_all can use it if needed
"cusip": cusip
})
except Exception as e:
print(f"CSV Loading Error: {e}")
print(f"CRITICAL: Failed to load instruments.csv: {e}")
return instruments
# URL_CONFIG = load_instruments_from_csv('instruments.csv')
def _ensure_data_exists(self):
if not os.path.exists(self.file_path):
# Check if this symbol exists in our master CSV mapping
match = next((i for i in self.instruments if i['symbol'].upper() == self.symbol), None)
if match:
print(f"DEBUG: Found {self.symbol} in master list. Fetching from {match['provider']}...")
self._download_from_provider(match)
else:
print(f"DEBUG: {self.symbol} not in master list. Trying generic Yahoo Finance...")
self._download_generic_yahoo()
def _download_generic_yahoo(self):
"""Standard yfinance fallback"""
try:
df = yf.download(self.symbol, period="max")
if not df.empty:
df.reset_index(inplace=True)
df.columns = [c.lower() for c in df.columns]
df.to_csv(self.file_path, index=False)
except Exception as e:
print(f"Yahoo fallback failed: {e}")
def global_sync(self):
"""Backup, Sync all instruments, and return a summary report."""
@@ -349,3 +423,152 @@ class DataEngine:
"last_ema200": get_ema_offset(200),
"kd_values": f"{k_val}/{d_val}" if k_val != "N/A" else "N/A"
}
class StrategyEngine:
"""
Handles financial strategy simulations and backtesting.
This class takes a DataEngine instance to access files.
"""
def __init__(self, data_engine):
# 1. Save the engine object (The 'Supplier')
self.data_engine = data_engine
# 2. Extract the symbol from the supplier so the chef knows the name
# We don't need .strip() here because DataEngine already did it!
self.symbol = data_engine.symbol
def _find_file(self):
# Try the uppercase version first
upper_path = os.path.join(self.data_dir, f"{self.symbol}.csv")
# Try the lowercase version second
lower_path = os.path.join(self.data_dir, f"{self.symbol.lower()}.csv")
if os.path.exists(upper_path):
return upper_path
elif os.path.exists(lower_path):
return lower_path
# If neither exists, print a very specific message to your terminal
print(f"ERROR: Searched for {upper_path} AND {lower_path} - Neither found!")
return None
def load_data(self):
df = pd.read_csv(self.file_path)
# Standardize column names to lowercase to avoid 'Price' vs 'price' issues
df.columns = [c.lower() for c in df.columns]
# Map common variations to a single 'price' column
if 'adj close' in df.columns:
df = df.rename(columns={'adj close': 'close'})
elif 'close' in df.columns:
df = df.rename(columns={'close': 'close'})
return df
def calculate_va_vs_dca(self, initial_inv, monthly_target, start_date, allow_sell=True, allow_fractional=True):
import math
# 1. Load and Prepare Data
df = pd.read_csv(self.data_engine.file_path)
df['date'] = pd.to_datetime(df['date'])
df = df.sort_values('date')
# 2. Identify the "Anchor Day" and the "Absolute Latest Day"
start_dt_obj = pd.to_datetime(start_date)
anchor_day = start_dt_obj.day
latest_csv_date = df['date'].max() # This captures 2026-01-27
# 3. Filter data starting from your start_date
df_filtered = df[df['date'] >= start_dt_obj].copy()
# 4. Select recurring monthly days (The first trading day on/after the anchor day)
monthly_df = df_filtered[df_filtered['date'].dt.day >= anchor_day].groupby([
df_filtered['date'].dt.year,
df_filtered['date'].dt.month
], as_index=False).first()
# 5. FORCE LAST ROW: If the latest date from CSV isn't in our list, append it
if monthly_df.empty or monthly_df.iloc[-1]['date'] != latest_csv_date:
last_row = df_filtered[df_filtered['date'] == latest_csv_date]
monthly_df = pd.concat([monthly_df, last_row]).drop_duplicates(subset=['date'])
# 6. Finalize index for the strategy loop
monthly_df.index = pd.to_datetime(monthly_df['date'])
if monthly_df.empty:
return []
# Helper for share calculation based on user toggle
def get_shares(cash, prc):
if prc <= 0: return 0
return cash / prc if allow_fractional else math.floor(cash / prc)
# 2. Initial Setup
va_shares = 0
dca_shares = 0
va_invested = 0
dca_invested = 0
va_target_value = 0
history = []
# 3. Strategy Loop
for i, row in monthly_df.iterrows():
actual_date_str = i.strftime('%Y-%m-%d')
price = float(row['close'])
if i == monthly_df.index[0]:
# --- MONTH 0: INITIAL DEPOSIT ---
actual_inv = initial_inv # This is the 'va_diff'
dca_actual_inv = initial_inv
va_target_value = initial_inv
diff = 0
va_new_shares = get_shares(actual_inv, price)
dca_new_shares = va_new_shares
else:
# --- MONTH 1+: DVA vs DCA ---
# DCA Logic
dca_actual_inv = monthly_target
dca_new_shares = get_shares(dca_actual_inv, price)
# DVA Logic (Fixed Value Path)
va_target_value += monthly_target
# Gap calculation: Target vs. current value BEFORE this month's investment
current_va_val_pre = va_shares * price
diff = va_target_value - current_va_val_pre
# Apply Buy/Sell constraints
actual_inv = diff if (diff >= 0 or allow_sell) else 0
va_new_shares = get_shares(actual_inv, price)
# --- STATE UPDATES (Must happen for both Month 0 and Month 1+) ---
va_shares += va_new_shares
dca_shares += dca_new_shares
va_invested += actual_inv
dca_invested += dca_actual_inv
# --- Unified History Append ---
# We calculate these here so they are ALWAYS defined for every row
history.append({
"date": actual_date_str,
"price": round(price, 2),
"dca_value": round(dca_shares * price, 2),
"dca_invested": round(dca_invested, 2),
"dca_shares_trans": round(dca_new_shares, 4),
"dca_shares_total": round(dca_shares, 4),
"va_value": round(va_shares * price, 2), # Becomes 'Current Portfolio Value'
"va_invested": round(va_invested, 2), # Becomes 'Total Invested'
"va_diff": round(actual_inv, 2),
"va_shares_trans": round(va_new_shares, 4),
"va_shares_total": round(va_shares, 4),
"va_target_value": round(va_target_value, 2) # Used for next goal
})
# Debugging print
print(f"Date: {i.strftime('%Y-%m')}, Target: {va_target_value:.2f}, Portfolio: {va_invested:.2f}, Diff: {diff:.2f}")
return history
+18
View File
@@ -297,6 +297,24 @@
window.onload = function() {
loadData();
};
scales: {
y: {
ticks: {
callback: function(value) {
return '$' + value.toLocaleString();
}
}
}
}
const vaROI = ((last.va_value - last.va_invested) / last.va_invested * 100).toFixed(2);
const dcaROI = ((last.dca_value - last.dca_invested) / last.dca_invested * 100).toFixed(2);
summaryText.innerHTML = `
<div class="row text-center">
<div class="col"><strong>VA ROI:</strong> ${vaROI}%</div>
<div class="col"><strong>DCA ROI:</strong> ${dcaROI}%</div>
</div>
`;
</script>
</body>
+446
View File
@@ -0,0 +1,446 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Value Averaging Pro | Analysis Tool</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Epilogue:wght@300;400;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
<style>
body { font-family: 'Epilogue', sans-serif; background-color: #f4f7fe; color: #1a1f36; }
.card { border: none; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.04); background: #fff; }
.kpi-card { border-radius: 12px; padding: 20px; text-align: center; height: 100%; border: 1px solid #edf2f7; }
.next-inv-header { background: #4d55ff; color: white; border-radius: 12px 12px 0 0; padding: 15px; }
/* Table Styling */
.table thead th { background-color: #1a1f36; color: white; vertical-align: middle; border: none; }
.table-striped tbody tr:nth-of-type(odd) { background-color: rgba(244, 247, 254, 0.7); }
.badge-ret { border-radius: 50px; padding: 6px 12px; font-weight: 700; font-size: 0.85rem; }
.col-dva { background-color: #2dce89 !important; color: white !important; }
.col-dca { background-color: #11cdef !important; color: white !important; }
/* Tab Styling */
.nav-pills .nav-link { color: #4d55ff; font-weight: 600; padding: 12px 30px; }
.nav-pills .nav-link.active { background-color: #4d55ff !important; }
.chart-container { position: relative; height: 500px; width: 100%; }
</style>
</head>
<body>
<div class="card shadow-sm p-4 mb-4">
<h4 class="fw-bold mb-4">🛠️ Value Averging Strategy Analysis</h4>
<form id="calcForm">
<div class="row g-3 mb-4">
<div class="col-md-3">
<label class="form-label small fw-bold text-uppercase">Ticker</label>
<input type="text" id="symbol" class="form-control" value="SPY">
</div>
<div class="col-md-3">
<label class="form-label small fw-bold text-uppercase">Initial ($)</label>
<input type="number" id="initial_inv" class="form-control" value="20000">
</div>
<div class="col-md-3">
<label class="form-label small fw-bold text-uppercase">Monthly Target ($)</label>
<input type="number" id="monthly_target" class="form-control" value="500">
</div>
<div class="col-md-3">
<label class="form-label small fw-bold text-uppercase">Start Date</label>
<input type="date" id="startDate" class="form-control" value="2024-01-01">
</div>
</div>
<div class="row g-3 align-items-center">
<div class="col-md-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="allow_sell" checked>
<label class="form-check-label fw-bold small text-uppercase" for="allow_sell">Allow Share Sales</label>
</div>
</div>
<div class="col-md-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="allow_fractional" checked>
<label class="form-check-label fw-bold small text-uppercase" for="allow_fractional">Fractional Shares</label>
</div>
</div>
<div class="col-md-6 d-flex justify-content-end gap-2">
<button type="button" onclick="runSimulation()" class="btn btn-primary px-5 fw-bold">Run Analysis</button>
<button type="button" onclick="resetAll()" class="btn btn-outline-secondary px-4">Reset</button>
</div>
</div>
</form>
</div>
<div id="kpiArea" class="d-none mb-4">
<div class="d-flex justify-content-end mb-2">
<span id="syncBadge" class="badge rounded-pill bg-success px-3">
Data Synced: <span id="lastSyncDate">YYYY-MM-DD</span>
</span>
</div>
<div class="row g-3 d-flex align-items-stretch">
<div class="col-md-4">
<div class="card p-3 text-center h-100">
<h6 class="text-muted text-uppercase small">Target Value Goal</h6>
<h2 id="targetValueCard" class="fw-bold mb-1">$0.00</h2>
<small class="text-muted">Expected value</small>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 text-center border-primary h-100">
<h6 class="text-muted text-uppercase small">Next Recommended Move</h6>
<h2 id="nextInvAmt" class="fw-bold mb-1">$0.00</h2>
<div class="small text-muted mb-2">
Price: <span id="latestPriceDisplay" class="fw-bold text-dark">$0.00</span>
as of <span id="priceDateLabel" class="fw-bold text-dark">YYYY-MM-DD</span>
</div>
<p id="nextInvMsg" class="mb-0 small text-muted">Calculating...</p>
</div>
</div>
<div class="col-md-4 d-flex flex-column">
<div class="card p-2 text-center flex-fill mb-2 d-flex justify-content-center">
<p class="text-muted small mb-0">Total Invested (DVA)</p>
<div id="totalSaved" class="h5 fw-bold mb-0">$0.00</div>
</div>
<div class="card p-2 text-center flex-fill d-flex justify-content-center">
<p class="text-muted small mb-0">Current Portfolio Value</p>
<div id="totalVal" class="h5 fw-bold mb-0">$0.00</div>
</div>
</div>
</div>
<div id="resultsArea" class="d-none">
<ul class="nav nav-pills nav-justified mb-3 card p-2 flex-row" id="mainTabs">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-chart">Performance Chart</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-table">Data Ledger</button></li>
</ul>
<div class="tab-content card p-4">
<div class="tab-pane fade show active" id="tab-chart">
<div class="chart-container">
<canvas id="mainChart"></canvas>
</div>
</div>
<div class="tab-pane fade" id="tab-table">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold m-0">Historical Ledger</h5>
<button class="btn btn-success btn-sm fw-bold" onclick="exportToExcel()">📥 Export to Excel</button>
</div>
<div class="table-responsive">
<table class="table table-striped align-middle border" id="ledgerTable">
<thead class="text-center">
<tr>
<th rowspan="2">Date</th>
<th rowspan="2">Price</th>
<th colspan="2" class="col-dva">Investment & Shares</th> <th colspan="2" class="col-dca">Total Value & Balance</th> <th colspan="2" class="bg-secondary">Annualized Return</th>
</tr>
<tr class="small">
<th class="col-dva">DVA ($ / Δ Shares)</th> <th class="col-dca">DCA</th>
<th class="col-dva">DVA ($ / Total Shrs)</th> <th class="col-dca">DCA</th>
<th class="col-dva">DVA (%)</th>
<th class="col-dca">DCA (%)</th>
</tr>
</thead>
<tbody id="tableBody" class="text-center"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let myChart = null;
async function runSimulation() {
// 1. Get references to all elements first
const el = {
symbol: document.getElementById('symbol'),
initial: document.getElementById('initial_inv'),
monthly: document.getElementById('monthly_target'),
date: document.getElementById('startDate'),
sell: document.getElementById('allow_sell'),
frac: document.getElementById('allow_fractional'),
btn: document.querySelector("button[onclick='runSimulation()']")
};
// 2. Safety Check: Ensure all elements exist before proceeding
for (const [key, element] of Object.entries(el)) {
if (!element) {
console.error(`Error: Element with ID or selector for '${key}' not found.`);
alert(`UI Error: Component '${key}' is missing. Please refresh.`);
return;
}
}
const originalText = el.btn.innerHTML;
try {
const payload = {
symbol: el.symbol.value.trim().toUpperCase(),
initial_inv: parseFloat(el.initial.value) || 0,
monthly_target: parseFloat(el.monthly.value) || 0,
start_date: el.date.value,
allow_sell: el.sell.checked,
allow_fractional: el.frac.checked
};
if (!payload.symbol) {
alert("Please enter a ticker symbol.");
return;
}
// Loading State
el.btn.disabled = true;
el.btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Loading...';
const res = await fetch('/api/backtest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = await res.json();
// Update UI
document.getElementById('kpiArea')?.classList.remove('d-none');
document.getElementById('resultsArea')?.classList.remove('d-none');
updateKPIs(data, payload.monthly_target);
renderDetailedChart(data);
renderTable(data);
} catch (err) {
console.error("Simulation Error:", err);
alert("Failed to run analysis. See console for details.");
} finally {
el.btn.disabled = false;
el.btn.innerHTML = originalText;
}
}
/**
* Updates the summary cards with the DVA "Next Move" recommendation.
* Uses the Fixed Value Path logic: (Last Target + Monthly Increment) - Current Value.
*/
/**
* Updates the Dashboard KPIs and Recommended Move
* @param {Array} data - The history array from the Python backend
* @param {number} monthlyTarget - The user-defined monthly investment increment
*/
function updateKPIs(data, monthlyTarget) {
if (!data || data.length === 0) return;
const last = data[data.length - 1];
// 1. Extract Data with multiple fallback options for key names
const latestPrice = last.price || last.close || 0;
const latestDate = last.date || "N/A";
const nextMoveAmount = last.va_diff || 0;
const sharesToMove = last.va_shares_trans || (latestPrice > 0 ? Math.abs(nextMoveAmount / latestPrice) : 0);
// Check these against your history.append keys
const totalInvestedValue = last.va_invested || 0;
const currentPortfolioVal = last.va_value || 0;
const currentTarget = last.va_target_value || 0;
// 2. Helper to set text safely
const updateEl = (id, val) => {
const el = document.getElementById(id);
if (el) el.innerText = val;
};
// 3. Update the UI
updateEl('targetValueCard', `$${currentTarget.toLocaleString(undefined, {minimumFractionDigits: 2})}`);
updateEl('totalSaved', `$${totalInvestedValue.toLocaleString(undefined, {minimumFractionDigits: 2})}`);
updateEl('totalVal', `$${currentPortfolioVal.toLocaleString(undefined, {minimumFractionDigits: 2})}`);
updateEl('priceDateLabel', latestDate);
updateEl('latestPriceDisplay', `$${latestPrice.toFixed(2)}`);
// 4. Handle the Recommendation Card
const amtEl = document.getElementById('nextInvAmt');
if (amtEl) {
// This ensures the minus sign shows up naturally from the number
const sign = nextMoveAmount < 0 ? "-" : (nextMoveAmount > 0 ? "+" : "");
amtEl.innerText = `${sign}$${Math.abs(nextMoveAmount).toLocaleString(undefined, {minimumFractionDigits: 2})}`;
amtEl.className = nextMoveAmount >= 0 ? "fw-bold text-primary" : "fw-bold text-danger";
}
// 5. Handle the Action Message
const msgEl = document.getElementById('nextInvMsg');
if (msgEl) {
const actionVerb = nextMoveAmount >= 0 ? "Buy" : "Sell";
const actionColor = nextMoveAmount >= 0 ? "text-primary" : "text-danger";
msgEl.innerHTML = `<span class="${actionColor} fw-bold">${actionVerb}</span> <strong>${Math.abs(sharesToMove).toFixed(4)}</strong> units @ $${latestPrice.toFixed(2)}`;
}
if (typeof updateSyncBadge === "function") updateSyncBadge(latestDate);
}
// Helper function to prevent errors if an ID is missing from HTML
function safeSetText(id, text) {
const el = document.getElementById(id);
if (el) el.innerText = text;
}
/**
* Updates a visual badge showing if data is fresh or stale
*/
function updateSyncBadge(dateString) {
const syncDateEl = document.getElementById('lastSyncDate');
const syncBadge = document.getElementById('syncBadge');
if (syncDateEl && syncBadge) {
syncDateEl.innerText = dateString;
// Calculate age of data
const dataDate = new Date(dateString);
const today = new Date();
const diffDays = Math.ceil(Math.abs(today - dataDate) / (1000 * 60 * 60 * 24));
// Turn badge red if data is older than 5 days
if (diffDays > 5) {
syncBadge.classList.replace('bg-success', 'bg-danger');
} else {
syncBadge.classList.replace('bg-danger', 'bg-success');
}
}
}
function renderDetailedChart(data) {
if(myChart) myChart.destroy();
const ctx = document.getElementById('mainChart').getContext('2d');
myChart = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => d.date),
datasets: [
{ label: 'Portfolio Value', data: data.map(d => d.va_value), borderColor: '#2dce89', yAxisID: 'y', tension: 0.3, fill: true, backgroundColor: 'rgba(45, 206, 137, 0.05)' },
{ label: 'Total Invested', data: data.map(d => d.va_invested), borderColor: '#5e72e4', yAxisID: 'y', tension: 0, borderDash: [5,5] },
{ label: 'Return (%)', data: data.map(d => ((d.va_value-d.va_invested)/d.va_invested*100).toFixed(2)), borderColor: '#f5365c', yAxisID: 'y1', tension: 0.3 }
]
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false }, // Critical for hover detail
plugins: {
tooltip: {
callbacks: {
label: function(context) {
let label = context.dataset.label || '';
let val = context.parsed.y;
return label.includes('%') ? `${label}: ${val}%` : `${label}: $${val.toLocaleString()}`;
}
}
}
},
scales: {
y: { type: 'linear', position: 'left', ticks: { callback: v => '$' + v.toLocaleString() } },
y1: { type: 'linear', position: 'right', grid: { drawOnChartArea: false }, ticks: { callback: v => v + '%' } }
}
}
});
}
// 2. Updated renderTable to remove text labels and fix ID reference
function renderTable(data) {
const tableBody = document.getElementById('tableBody');
const initialInput = parseFloat(document.getElementById('initial_inv').value) || 0;
const monthlyInput = parseFloat(document.getElementById('monthly_target').value) || 0;
if (!tableBody) return;
tableBody.innerHTML = data.map((d, index) => {
// Use initial investment for row 0, monthly target for others
const dcaInvestedThisMonth = index === 0 ? initialInput : monthlyInput;
// Calculate Returns for badges
const vaAnnRet = d.va_invested > 0 ? (((d.va_value - d.va_invested) / d.va_invested) * 100).toFixed(2) : 0;
const dcaAnnRet = d.dca_invested > 0 ? (((d.dca_value - d.dca_invested) / d.dca_invested) * 100).toFixed(2) : 0;
return `
<tr>
<td class="small fw-bold">${d.date}</td>
<td>$${d.price.toLocaleString(undefined, {minimumFractionDigits: 2})}</td>
<td class="text-success fw-bold">
$${d.va_diff.toLocaleString()}<br>
<small class="text-muted">${d.va_shares_trans >= 0 ? '+' : ''}${d.va_shares_trans.toFixed(2)}</small>
</td>
<td>
$${dcaInvestedThisMonth.toLocaleString()}<br>
<small class="text-muted">+${d.dca_shares_trans.toFixed(2)}</small>
</td>
<td class="fw-bold">
$${d.va_value.toLocaleString()}<br>
<small class="text-primary">${d.va_shares_total.toFixed(2)}</small>
</td>
<td>
$${d.dca_value.toLocaleString()}<br>
<small class="text-primary">${d.dca_shares_total.toFixed(2)}</small>
</td>
<td>
<span class="badge ${vaAnnRet >= 0 ? 'bg-success' : 'bg-danger'}">
${vaAnnRet}%
</span>
</td>
<td>
<span class="badge ${dcaAnnRet >= 0 ? 'bg-info' : 'bg-danger'}">
${dcaAnnRet}%
</span>
</td>
</tr>`;
}).join('');
}
function exportToExcel() {
const table = document.getElementById("ledgerTable");
const wb = XLSX.utils.table_to_book(table);
XLSX.writeFile(wb, "Investment_Backtest_Results.xlsx");
}
function resetAll() {
// 1. Reset Inputs to Defaults
const defaults = {
'symbol': 'SPY',
'initial_inv': 20000,
'monthly_target': 500,
'startDate': '2024-01-01'
};
Object.keys(defaults).forEach(id => {
const input = document.getElementById(id);
if (input) input.value = defaults[id];
});
// 2. Reset Toggles
const sellToggle = document.getElementById('allow_sell');
const fracToggle = document.getElementById('allow_fractional');
if (sellToggle) sellToggle.checked = true;
if (fracToggle) fracToggle.checked = true;
// 3. Hide Result Sections
document.getElementById('kpiArea')?.classList.add('d-none');
document.getElementById('resultsArea')?.classList.add('d-none');
// 4. Clear Data
const tableBody = document.getElementById('tableBody');
if (tableBody) tableBody.innerHTML = '';
// 5. Clear Chart if it exists
if (window.detailedChart) {
window.detailedChart.destroy();
window.detailedChart = null;
}
console.log("Dashboard reset successfully.");
}
</script>
</body>
</html>