Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
101 lines
3.9 KiB
HTML
101 lines
3.9 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Cassandra · Import Portfolio{% endblock %}
|
|
|
|
{% block main %}
|
|
<section class="panel" style="grid-column: 1 / -1; max-width: 760px; margin: 0 auto;">
|
|
<div class="panel-header">
|
|
<span class="title">Import portfolio (Trading 212 CSV)</span>
|
|
<span class="meta">stays in your browser · never persists server-side</span>
|
|
</div>
|
|
|
|
<div class="panel-body" style="padding: 18px clamp(16px, 4vw, 32px) 24px;">
|
|
<p style="color: var(--muted); font-size: 12.5px; margin: 0 0 14px; line-height: 1.6;">
|
|
Export your pie from the T212 web app
|
|
(<span class="neu">Trading 212 → Investing → Your Pie → ⋯ → Export</span>)
|
|
and drop the CSV here. Cassandra resolves each Slice to its Yahoo
|
|
ticker; the parsed pie is kept in <em>this browser's localStorage</em>
|
|
only. The server learns just which tickers exist (anonymously) so it
|
|
can fetch their prices.
|
|
</p>
|
|
|
|
<form id="upload-form" autocomplete="off">
|
|
<div id="drop-zone" class="dz">
|
|
<input type="file" id="file-input" name="file" accept=".csv,text/csv" hidden>
|
|
<div class="dz__icon">▱</div>
|
|
<div class="dz__label">Drop a T212 pie CSV here</div>
|
|
<div class="dz__hint">or <a href="#" id="browse-link">browse</a> · max 1 MB</div>
|
|
<div class="dz__filename" id="dz-filename"></div>
|
|
</div>
|
|
|
|
<button id="submit-btn" type="submit" disabled style="margin-top:18px;">Parse</button>
|
|
</form>
|
|
|
|
<div id="result" class="result" hidden></div>
|
|
</div>
|
|
</section>
|
|
|
|
<script src="{{ url_for('static', path='/js/portfolio.js') }}" defer></script>
|
|
<script>
|
|
(function () {
|
|
function ready(fn) {
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', fn);
|
|
} else { fn(); }
|
|
}
|
|
|
|
ready(function () {
|
|
var dropZone = document.getElementById('drop-zone');
|
|
var fileInput = document.getElementById('file-input');
|
|
var browseLink = document.getElementById('browse-link');
|
|
var filenameEl = document.getElementById('dz-filename');
|
|
var submitBtn = document.getElementById('submit-btn');
|
|
var form = document.getElementById('upload-form');
|
|
var resultEl = document.getElementById('result');
|
|
|
|
function setFile(file) {
|
|
if (!file) return;
|
|
var dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
fileInput.files = dt.files;
|
|
filenameEl.textContent = file.name + ' (' + Math.round(file.size / 1024) + ' KB)';
|
|
submitBtn.disabled = false;
|
|
}
|
|
|
|
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
|
|
fileInput.addEventListener('change', function () {
|
|
if (fileInput.files[0]) setFile(fileInput.files[0]);
|
|
});
|
|
|
|
['dragenter', 'dragover'].forEach(function (ev) {
|
|
dropZone.addEventListener(ev, function (e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
dropZone.classList.add('dz--over');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach(function (ev) {
|
|
dropZone.addEventListener(ev, function (e) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
dropZone.classList.remove('dz--over');
|
|
});
|
|
});
|
|
dropZone.addEventListener('drop', function (e) {
|
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) setFile(e.dataTransfer.files[0]);
|
|
});
|
|
dropZone.addEventListener('click', function (e) {
|
|
if (e.target.tagName !== 'A') fileInput.click();
|
|
});
|
|
|
|
form.addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
if (!fileInput.files[0]) return;
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Parsing…';
|
|
// CassandraPortfolio is exposed by /static/js/portfolio.js.
|
|
var ok = await window.CassandraPortfolio.handleUpload(form, fileInput.files[0], resultEl);
|
|
submitBtn.textContent = ok ? 'Parsed' : 'Parse';
|
|
submitBtn.disabled = !ok;
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|