initial commit — cassandra v0.1

Containerised macro-strategy dashboard: 4-panel web UI (indicators,
portfolio, flash news, AI strategic log), MariaDB store, hourly
ingestion jobs, OpenRouter-backed AI analysis.

Ports the four prototype scripts in the parent dir (market_pulse,
flash_news, trading212, strategic_log) into async services backed by a
persistent DB and served via FastAPI + Jinja2 + HTMX. APScheduler runs
as a separate compose service for crash-safety and easier restarts.

Portfolio composition + position names come live from Trading 212;
news per-ticker headlines reuse those names. Tone (NOVICE/INTERMEDIATE/
PRO) and analysis style (DRY/SPECULATIVE) are env-configurable and
stored on each log row so historical entries show what produced them.

Default model is deepseek/deepseek-v4-flash (overridable via env).
Light/dark theme toggle, sans-serif for prose surfaces, monospace for
data. Bearer-token auth, OpenRouter monthly cost cap, RSS feeds auto-
disabled on consecutive failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-15 21:56:10 +01:00
commit a10409c02b
61 changed files with 4890 additions and 0 deletions

73
app/static/js/chat.js Normal file
View file

@ -0,0 +1,73 @@
// Cassandra chat sidebar — ephemeral, client-state conversation.
// No persistence: page refresh starts a new chat.
(() => {
const thread = document.getElementById('chat-thread');
const form = document.getElementById('chat-form');
const input = document.getElementById('chat-input');
const send = document.getElementById('chat-send');
if (!thread || !form || !input || !send) return;
/** @type {{role: string, content: string}[]} */
const messages = [];
function escapeHTML(s) {
return s.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function append(role, html_or_text, opts) {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + role;
if (opts && opts.html) {
div.innerHTML = html_or_text;
} else {
div.textContent = html_or_text;
}
thread.appendChild(div);
thread.scrollTop = thread.scrollHeight;
return div;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text || send.disabled) return;
messages.push({role: 'user', content: text});
append('user', text);
input.value = '';
send.disabled = true;
const thinking = append('assistant pending', '…');
try {
const r = await fetch('/api/chat', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({messages}),
});
if (!r.ok) {
const msg = await r.text();
thinking.className = 'chat-msg chat-msg--error';
thinking.textContent = 'HTTP ' + r.status + ': ' + msg.slice(0, 300);
return;
}
const data = await r.json();
thinking.className = 'chat-msg chat-msg--assistant';
thinking.innerHTML = data.content_html || escapeHTML(data.content);
messages.push({role: 'assistant', content: data.content});
} catch (err) {
thinking.className = 'chat-msg chat-msg--error';
thinking.textContent = 'error: ' + err.message;
} finally {
send.disabled = false;
input.focus();
}
});
// Enter to send; Shift+Enter for newline.
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
})();

1
app/static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long