phase G: data minimisation + passwordless auth + DeepSeek-first LLM

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>
This commit is contained in:
Giorgio Gilestro 2026-05-18 14:16:57 +01:00
parent 480fd311c5
commit 6e7f57c6b2
54 changed files with 5005 additions and 916 deletions

View file

@ -5,15 +5,17 @@
<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">no broker credentials required</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. We resolve each Slice to its Yahoo ticker via
a catalogue we maintain in the background.
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">
@ -21,137 +23,79 @@
<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 2 MB</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>
<div class="form-row" style="margin-top: 14px;">
<label for="portfolio-name">Portfolio name (optional)</label>
<input type="text" id="portfolio-name" name="portfolio_name"
placeholder="auto-derived from CSV's Total row" maxlength="64">
</div>
<div class="form-row" style="margin-top: 6px;">
<label for="currency">Account currency</label>
<select id="currency" name="currency">
<option value="GBP">GBP</option>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
</select>
</div>
<button id="submit-btn" type="submit" disabled>Import</button>
<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 () {
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;
function ready(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else { fn(); }
}
browseLink.addEventListener('click', function (e) { e.preventDefault(); fileInput.click(); });
fileInput.addEventListener('change', function () {
if (fileInput.files[0]) setFile(fileInput.files[0]);
});
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');
['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 = 'Importing…';
resultEl.hidden = true;
resultEl.className = 'result';
var fd = new FormData();
fd.append('file', fileInput.files[0]);
var name = document.getElementById('portfolio-name').value.trim();
if (name) fd.append('portfolio_name', name);
fd.append('currency', document.getElementById('currency').value);
try {
var r = await fetch('/api/portfolios/upload', { method: 'POST', body: fd });
var data = await r.json();
if (!r.ok) {
renderError(data.detail || ('HTTP ' + r.status));
return;
}
renderSuccess(data);
} catch (err) {
renderError(err.message);
} finally {
submitBtn.textContent = 'Import';
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;
});
});
function fmt(n) {
return (n === null || n === undefined) ? '—' : Number(n).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
}
function renderSuccess(d) {
var unmappedTxt = d.unmapped && d.unmapped.length
? '<div class="result__warn"><strong>' + d.unmapped.length + ' unmapped slice(s):</strong> '
+ d.unmapped.map(function(s) { return '<code>' + s + '</code>'; }).join(', ')
+ ' — these wont get live prices until the catalogue is extended.</div>'
: '<div class="result__row neu">All slices resolved to Yahoo tickers.</div>';
resultEl.className = 'result result--ok';
resultEl.innerHTML =
'<div class="result__head">▸ Imported <strong>' + d.portfolio_name + '</strong>'
+ (d.is_new_portfolio ? ' <span class="result__tag">new</span>' : ' <span class="result__tag">new snapshot</span>')
+ '</div>'
+ '<div class="result__grid">'
+ '<div><div class="k">Positions</div><div class="v">' + d.positions + '</div></div>'
+ '<div><div class="k">Invested</div><div class="v">' + fmt(d.invested) + '</div></div>'
+ '<div><div class="k">Value</div><div class="v">' + fmt(d.value) + '</div></div>'
+ '<div><div class="k">Result</div><div class="v ' + (d.result >= 0 ? 'pos' : 'neg') + '">'
+ (d.result >= 0 ? '+' : '') + fmt(d.result) + '</div></div>'
+ '</div>'
+ unmappedTxt
+ '<div class="result__row"><a href="/">Back to dashboard →</a></div>';
resultEl.hidden = false;
}
function renderError(msg) {
resultEl.className = 'result result--err';
resultEl.innerHTML = '<div class="result__head">✕ Import failed</div><div class="result__row">'
+ String(msg).replace(/[<>]/g, '') + '</div>';
resultEl.hidden = false;
}
})();
</script>
{% endblock %}