-
Notifications
You must be signed in to change notification settings - Fork 191
Description
"""
SMPLINVST — Investing made clear, opportunities made simple.
This single Flask app includes:
- Profile personalization
- Brands → Suppliers
- Compare view (with tickers wired for prices when POLYGON_API_KEY is set)
- Watchlist
- AI Coach (OpenAI if OPENAI_API_KEY set; otherwise calm default)
- Humanized Tailwind UI
- Integrated React single-file demo at /react (X-Ray, Compare, Education, Coach)
Run:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install flask requests python-dotenv
(optional) pip install openai
python smplinvst_app.py
Open:
http://127.0.0.1:5000 (Flask UI)
http://127.0.0.1:5000/react (React single-file demo)
"""
import os
import sqlite3
from contextlib import closing
from typing import Optional
import requests
from flask import Flask, request, redirect, url_for, render_template_string, flash
from jinja2 import DictLoader
from dotenv import load_dotenv
--- OpenAI is optional; fail-safe if not installed or not configured ---
try:
import openai # type: ignore
_OPENAI_IMPORTED = True
except Exception:
openai = None # type: ignore
_OPENAI_IMPORTED = False
-------------------------
Config
-------------------------
APP_NAME = "SMPLINVST"
DB_PATH = "smplinvst.db"
load_dotenv()
POLYGON_API_KEY = os.getenv("POLYGON_API_KEY")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
if _OPENAI_IMPORTED and OPENAI_API_KEY:
try:
# Old SDK interface for broad compatibility
openai.api_key = OPENAI_API_KEY # type: ignore[attr-defined]
except Exception:
pass
app = Flask(name)
app.secret_key = os.getenv("FLASK_SECRET_KEY", "dev-secret-change-me") # replace for prod
-------------------------
Domain Data (tickers included)
-------------------------
BRANDS = {
"Apple": {
"ticker": "AAPL",
"summary": "Consumer tech giant. The hidden opportunity often lies in its suppliers.",
"suppliers": [
{"name": "TSMC", "ticker": "TSM", "why": "Advanced chips powering Apple devices."},
{"name": "Foxconn", "ticker": None, "why": "Assembles iPhones and other hardware."},
{"name": "Corning", "ticker": "GLW", "why": "Specialty glass for Apple products."},
],
},
"Nike": {
"ticker": "NKE",
"summary": "Global apparel brand. Margins hinge on suppliers’ scale and efficiency.",
"suppliers": [
{"name": "Sheng Hung", "ticker": None, "why": "Textiles and performance fabrics."},
{"name": "Fulgent Sun", "ticker": None, "why": "Large-scale footwear manufacturing."},
{"name": "Avery Dennison", "ticker": "AVY", "why": "Labels and supply-chain tech."},
],
},
"Tesla": {
"ticker": "TSLA",
"summary": "EV pioneer. Battery ecosystem is the real story.",
"suppliers": [
{"name": "Panasonic", "ticker": "PCRFY", "why": "Battery cells for Tesla models."},
{"name": "CATL", "ticker": None, "why": "LFP battery supplier in key regions."},
{"name": "NVIDIA", "ticker": "NVDA", "why": "Compute for AI/Autopilot systems."},
],
},
}
-------------------------
Database
-------------------------
SCHEMA = """
CREATE TABLE IF NOT EXISTS profile (
id INTEGER PRIMARY KEY CHECK (id=1),
first_name TEXT,
life_goal TEXT,
risk_profile TEXT
);
CREATE TABLE IF NOT EXISTS watchlist (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_type TEXT CHECK(item_type IN ('brand','supplier')) NOT NULL,
name TEXT NOT NULL
);
"""
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
with closing(get_db()) as db:
db.executescript(SCHEMA)
db.commit()
@app.before_first_request
def _init():
init_db()
-------------------------
Helpers
-------------------------
def get_price(ticker: Optional[str]) -> str:
"""Fetch last trade price from Polygon if configured; otherwise '—'."""
if not ticker or not POLYGON_API_KEY:
return "—"
try:
r = requests.get(
f"https://api.polygon.io/v2/last/trade/{ticker}",
params={"apiKey": POLYGON_API_KEY},
timeout=6,
)
if r.ok:
js = r.json() or {}
price = (js.get("results") or {}).get("p")
return f"${price:.2f}" if isinstance(price, (float, int)) else "N/A"
except Exception:
return "N/A"
return "N/A"
def ai_coach(note: Optional[str] = None) -> str:
"""Calm, encouraging coach. Uses OpenAI if key present & SDK available; else a default tip."""
if not (_OPENAI_IMPORTED and OPENAI_API_KEY):
return "Coach’s note: Tiny steps → big outcomes. Stay consistent, stay calm."
# Try legacy ChatCompletion first for compatibility
try:
# type: ignore[union-attr]
resp = openai.ChatCompletion.create( # noqa
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a calm, encouraging investing coach for SMPLINVST."},
{"role": "user", "content": note or "Give a short, plain-language investing encouragement."},
],
max_tokens=120,
temperature=0.6,
)
return resp["choices"][0]["message"]["content"].strip()
except Exception:
# Final fallback — static tip
return "Coach unavailable right now. Keep the process small and steady."
-------------------------
HTML Templates (Flask UI)
-------------------------
BASE_HTML = """
<!doctype html>
{{ app_name }}
🏠 Home 📈 Compare 💡 Coach 🧪 React Demo Reset {% with messages = get_flashed_messages() %} {% if messages %} {% for m in messages %}HOME_HTML = """
{% extends 'base.html' %}
{% block content %}
{% if not profile %}
Personalize your journey
ConservativeBalancedAggressive Save {% else %}Hey {{ profile['first_name'] }}, your goal is: {{ profile['life_goal'] }}
Risk: {{ profile['risk_profile'] }}
Brands → Suppliers
{{ brand }}
+ Watch{{ info.summary }}
Stock: {{ get_price(info.ticker) }}
-
{% for s in info.suppliers %}
- {{ s.name }} + Watch {% endfor %}
⭐ Your Watchlist
{% if not watchlist %}Nothing yet. Add brands or suppliers that resonate with you.
{% else %}-
{% for row in watchlist %}
- {{ row['name'] }} ({{ row['item_type'] }}) Remove {% endfor %}
COMPARE_HTML = """
{% extends 'base.html' %}
{% block content %}
Compare
{% for b in brands.keys() %} {{ b }} {% endfor %} Compare {% if picked %}{{ picked }}
{{ brands[picked].summary }}
Stock: {{ get_price(brands[picked].ticker) }}
{{ s.name }}
{{ s.why }}
Stock: {{ get_price(s.ticker) }}
{{ coach_note }}
COACH_HTML = """
{% extends 'base.html' %}
{% block content %}
💡 AI Coach
Ask {% if answer %}{{ answer }}
{{ default_note }}
-------------------------
React Demo Page (/react)
-------------------------
REACT_HTML = """
<!doctype html>
// React resolver
let ReactLocal;
try { ReactLocal = require('react'); } catch { ReactLocal = (globalThis && (globalThis.React || globalThis.preactCompat || globalThis.preact)) || null; }
if (!ReactLocal) { throw new Error('SMPLINVST: React not available. Provide window.React or enable require("react").'); }
const React = ReactLocal;
// --- Data ---
const DB = {
brands: {
Apple: {
ticker: 'AAPL',
thesis: 'Brand scale + ecosystem lock-in. Supplier edge: concentrated tech margin capture (chips/displays).',
suppliers: [
{ name: 'TSMC', ticker: 'TSM', role: 'Chips / Foundry', risk: 3 },
{ name: 'Broadcom', ticker: 'AVGO', role: 'Wireless Chips', risk: 2 },
{ name: 'LG Display', ticker: 'LPL', role: 'Displays', risk: 4 }
]
},
Nike: {
ticker: 'NKE',
thesis: 'Global brand + distribution. Supplier angle: inputs & scale logistics.',
suppliers: [
{ name: '3M', ticker: 'MMM', role: 'Materials', risk: 2 },
{ name: 'Amcor', ticker: 'AMCR', role: 'Packaging', risk: 2 },
{ name: 'VF Corp', ticker: 'VFC', role: 'Materials/Apparel', risk: 4 }
]
},
Starbucks: {
ticker: 'SBUX',
thesis: 'Consumer habit + footprint. Supplier angle: distribution & packaging.',
suppliers: [
{ name: 'PepsiCo', ticker: 'PEP', role: 'Distribution', risk: 2 },
{ name: 'Nestlé', ticker: 'NSRGY', role: 'Canned coffee partner', risk: 2 },
{ name: 'Amcor', ticker: 'AMCR', role: 'Packaging', risk: 3 }
]
}
},
prices: {}
};
(function seedPrices(){
function rng(seed){ let s=seed; return ()=> (s = (s1664525+1013904223)>>>0)/4294967296; }
function makeSeries(seed){
const r=rng(seed), arr=[]; let p=100+50r();
for(let i=0;i<30;i++){ const drift=(r()-0.5)*2; p=Math.max(5, p+drift); arr.push(+p.toFixed(2)); }
return arr;
}
const tickers = ['AAPL','TSM','AVGO','LPL','NKE','MMM','AMCR','VFC','SBUX','PEP','NSRGY'];
tickers.forEach((t,i)=> DB.prices[t]=makeSeries(12345+i));
})();
// --- Utils ---
function safeDecode(v){ try { return decodeURIComponent(v ?? ''); } catch { return String(v ?? ''); } }
function clamp(n,min,max){ return Math.min(max, Math.max(min,n)); }
function percent(a,b){ if(!a||!b) return 0; return ((a-b)/b)100; }
function mean(xs){ return xs.reduce((s,x)=>s+x,0)/xs.length; }
function stdev(xs){ const m=mean(xs); const v=mean(xs.map(x=>(x-m)(x-m))); return Math.sqrt(v); }
function returns(xs){ const r=[]; for(let i=1;i<xs.length;i++){ r.push((xs[i]-xs[i-1])/xs[i-1]); } return r; }
function metricsFor(ticker){
const s = DB.prices[ticker] || [];
if (s.length<2) return { last:null, first:null, changePct:null, vol:null };
const last=s[s.length-1], first=s[0];
const r = returns(s);
return { last, first, changePct: percent(last, first), vol: stdev(r)Math.sqrt(252) };
}
function sparkpath(xs,width=160,height=40){
if(!xs||!xs.length) return '';
const min=Math.min(...xs), max=Math.max(...xs); const span=max-min||1;
const step=width/(xs.length-1); let d='';
for(let i=0;i<xs.length;i++){
const x=istep; const y=height - ((xs[i]-min)/span)*height;
d += (i? ' L':'M')+x.toFixed(2)+' '+y.toFixed(2);
}
return d;
}
function persist(key, val){ try{ localStorage.setItem(key, JSON.stringify(val)); }catch{} }
function restore(key, dflt){ try{ const v=localStorage.getItem(key); return v? JSON.parse(v): dflt; }catch{ return dflt; } }
function parseHashPath(){ const raw=(typeof window!=='undefined'?window.location.hash:'')||'#/' ; const path=raw.startsWith('#')?raw.slice(1):raw; const cleaned=path.trim()||'/'; const parts=cleaned.replace(/^/+|/+$/g,'').split('/'); if(parts.length===1&&(parts[0]===''||parts[0]==='/')) return []; return parts.map(safeDecode); }
function matchRoute(pattern, parts){ const out={ok:false, params:{}}; if(!Array.isArray(parts)) return out; if(pattern.length!==parts.length) return out; const params={}; for(let i=0;i<pattern.length;i++){ const seg=pattern[i], val=parts[i]; if(seg.startsWith(':')) params[seg.slice(1)]=val; else if(seg!==val) return out; } return {ok:true, params}; }
function navigate(hash){ if(typeof window==='undefined') return; const t=hash.startsWith('#')?hash:'#'+hash; if(window.location.hash===t) return; window.location.hash=t; }
// --- Intent coach (no network) ---
function coachAnswer(q){
const s = (q||'').toLowerCase();
if(/supplier|supply|behind the brand/.test(s)) return 'Suppliers make critical parts behind big brands. Owning suppliers can capture value the logo doesn’t — esp. chips.';
if(/risk|vol/.test(s)) return 'Rule: brands ≈ steadier cashflows; suppliers ≈ higher cyclicality. Match to your risk slider and diversify.';
if(/apple|tsm|avgo/.test(s)){
const a=metricsFor('AAPL'), t=metricsFor('TSM');
return AAPL ${a.changePct? a.changePct.toFixed(1)+'%':''} vs TSM ${t.changePct? t.changePct.toFixed(1)+'%':''} (30d). Brands defend demand; foundries monetize scale.
;
}
if(/diversif/.test(s)) return 'Spread positions across brand and 2–3 suppliers. Avoid single-point failures. Rebalance quarterly.';
return 'Ask about suppliers vs brands, risk, or Apple/Nike/Starbucks.';
}
// --- Styles ---
const css = {
page: { minHeight:'100vh', background:'#000', color:'#fff', fontFamily:'system-ui,-apple-system,Segoe UI,Roboto,Inter,sans-serif' },
header: { padding:'12px 16px', borderBottom:'1px solid rgba(255,255,255,0.12)', display:'flex', justifyContent:'space-between', alignItems:'center' },
navlink: { color:'#bbb', textDecoration:'none', marginLeft:12 },
button: { padding:'10px 14px', background:'#059669', borderRadius:10, color:'#fff', textDecoration:'none', display:'inline-block' },
card: { border:'1px solid rgba(255,255,255,0.12)', background:'rgba(255,255,255,0.04)', borderRadius:16, padding:16 },
muted: { color:'#bbb' }
};
// --- UI bits ---
function Badge({label, tone}){ const bg = tone==='good'? '#064e3b' : tone==='warn'? '#7c2d12' : '#1f2937';
return <span style={{ background:bg, color:'#fff', padding:'4px 8px', borderRadius:999, fontSize:12, marginLeft:8 }}>{label};
}
function Metric({label, value}){ return (<div style={{ display:'flex', justifyContent:'space-between', marginTop:6 }}><span style={{ color:'#aaa' }}>{label}{value}); }
function Spark({series, width=160, height=40}){ const d=sparkpath(series,width,height); const up=(series?.[series.length-1]||0)>=(series?.[0]||0); const stroke=up?'#10b981':'#ef4444'; return (<svg width={width} height={height} style={{ display:'block', width:'100%' }}>); }
// --- Pages ---
function HomePage(){ return (
See Beyond the Logo
SMPLINVST reveals the supplier layer — the backbone behind logos.
function SupplierChip({s, brand}){ return (
<a href={#/compare/${encodeURIComponent(brand)}/${encodeURIComponent(s.ticker)}
}
onClick={(e)=>{e.preventDefault();navigate(#/compare/${encodeURIComponent(brand)}/${encodeURIComponent(s.ticker)}
);}}
style={{ display:'inline-block', margin:6, padding:'8px 12px', borderRadius:12, border:'1px solid rgba(255,255,255,0.16)', background:'rgba(255,255,255,0.04)', textDecoration:'none', color:'#fff' }}>
<div style={{ fontWeight:600 }}>{s.name} <span style={{ color:'#9ca3af' }}>({s.ticker})
<div style={{ fontSize:12, color:'#9ca3af' }}>{s.role}
);}
function XRayPage({ brand }){ const b=DB.brands[brand]; if(!b) return (
Brand not found
Try Apple, Nike, or Starbucks.
{b.thesis}
function CompareCard({ title, ticker, risk, personalizedRisk }){ const s=DB.prices[ticker]||[]; const m=metricsFor(ticker); const fit=personalizedRisk!=null?(personalizedRisk>=(risk||3)?'Fit':'Cautious'):null; return (
function ComparePage({ brand, supplier }){ const b=DB.brands[brand]; const suppliers=b?.suppliers||[]; const mapped=suppliers.find(x=>x.ticker===supplier); const riskPref=restore('smplinvst:risk',3);
if(!b) return (<section style={{ padding:20 }}><h2 style={{ fontSize:24, fontWeight:800 }}>Brand not found
Try Apple, Nike, or Starbucks.
);if(!mapped) return (
<section style={{ padding:20, maxWidth:1000, margin:'0 auto' }}>
<h2 style={{ fontSize:24, fontWeight:800 }}>{brand} vs {supplier}
Support for this supplier isn’t available yet — it’s being added.
<div style={{ marginTop:12 }}>Try one of the mapped suppliers:
<div style={{ marginTop:8 }}>{suppliers.map((s)=> )}
);
const left={ title:
${brand} (Brand)
, ticker:b.ticker, risk:2 }, right={ title:${mapped.name} (Supplier)
, ticker:mapped.ticker, risk:mapped.risk };return (
<section style={{ padding:20, maxWidth:1000, margin:'0 auto' }}>
<h2 style={{ fontSize:24, fontWeight:800, marginBottom:12 }}>{brand}: Brand vs Supplier
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12 }}>
<div style={{ display:'grid', gridTemplateColumns:'1fr 1fr', gap:12, marginTop:12 }}>
<div style={{ ...css.muted, marginTop:12 }}>*Data is illustrative (synthetic 30d). Wire Polygon later for live quotes.
);
}
function EducationPage(){ return (
Education
A supplier sells critical parts to many brands. That diversification can smooth single-brand risk while retaining margin exposure.
Higher tech concentration → higher volatility. Use the risk slider to match positions to your tolerance.
// Sidebar coach & risk
class Coach extends React.Component{ constructor(p){ super(p); this.state={ open:true, q:'Explain suppliers vs brands', a:'' }; }
ask(){ const a=coachAnswer(this.state.q); this.setState({a}); }
render(){ if(!this.state.open) return (<div style={{ position:'fixed', right:16, bottom:16 }}><button onClick={()=>this.setState({open:true})} style={{ ...css.button, background:'#1f2937' }}>Coach);
return (<div style={{ position:'fixed', right:16, bottom:16, width:320 }}>
<div style={{ ...css.card }}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:6 }}>
<div style={{ fontWeight:700 }}>Coach
<button onClick={()=>this.setState({open:false})} style={{ ...css.button, background:'#374151', padding:'6px 10px' }}>Hide
<textarea value={this.state.q} onChange={e=>this.setState({q:e.target.value})}
style={{ width:'100%', height:72, background:'rgba(255,255,255,0.06)', color:'#fff', border:'1px solid rgba(255,255,255,0.18)', borderRadius:8, padding:8 }} />
<div style={{ display:'flex', justifyContent:'flex-end', marginTop:8 }}>
<button onClick={()=>this.ask()} style={{ ...css.button }}>Ask
{this.state.a && <div style={{ marginTop:8, color:'#e5e7eb', whiteSpace:'pre-wrap' }}>{this.state.a}}
);
}
}
class RiskControl extends React.Component{ constructor(p){ super(p); this.state={ val: restore('smplinvst:risk', 3) }; }
set(v){ const nv=Math.min(5, Math.max(1, Number(v))); this.setState({val:nv}); persist('smplinvst:risk', nv); if(this.props.onChange) this.props.onChange(nv); }
render(){ return (
<div style={{ ...css.card, display:'flex', alignItems:'center', gap:12 }}>
<div style={{ fontWeight:700 }}>Risk preference
<input type="range" min="1" max="5" value={this.state.val} onChange={e=>this.set(e.target.value)} />
<div style={{ color:'#d1d5db' }}>{this.state.val}
);}
}
// Layout & App
function Header(){ return (
class App extends React.Component{
constructor(p){ super(p); this.state={ parts: parseHashPath(), risk: restore('smplinvst:risk',3) }; this.onHash=this.onHash.bind(this); }
componentDidMount(){ if(typeof window!=='undefined' && !window.location.hash) navigate('#/'); window.addEventListener('hashchange', this.onHash); }
componentWillUnmount(){ window.removeEventListener('hashchange', this.onHash); }
onHash(){ this.setState({ parts: parseHashPath() }); }
render(){ const { parts } = this.state; let content=null;
if(parts.length===0){ content=; }
if(!content){ const m=matchRoute(['xray',':brand'], parts); if(m.ok) content=; }
if(!content){ const m=matchRoute(['compare',':brand',':supplier'], parts); if(m.ok) content=; }
if(!content){ const m=matchRoute(['edu'], parts); if(m.ok) content=; }
if(!content){ content=(<section style={{ padding:20 }}><h2 style={{ fontSize:22, fontWeight:800 }}>Not Found
Go <a href="#/" onClick={(e)=>{e.preventDefault();navigate('#/');}} style={{ color:'#9ca3af' }}>Home.
); }return (
}
}
// Mount
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render();
</script>
-------------------------
Routes (Flask UI)
-------------------------
@app.route("/")
def home():
with closing(get_db()) as db:
profile = db.execute("SELECT * FROM profile WHERE id=1").fetchone()
watchlist = db.execute("SELECT * FROM watchlist ORDER BY id DESC").fetchall()
return render_template_string(
HOME_HTML,
app_name=APP_NAME,
title="Home",
profile=profile,
brands=BRANDS,
watchlist=watchlist,
get_price=get_price,
)
@app.route("/set_profile", methods=["POST"])
def set_profile():
first_name = (request.form.get("first_name") or "").strip()
life_goal = (request.form.get("life_goal") or "").strip()
risk_profile = (request.form.get("risk_profile") or "Balanced").strip()
if not first_name or not life_goal:
flash("Please complete your name and goal.")
return redirect(url_for("home"))
with closing(get_db()) as db:
db.execute("DELETE FROM profile")
db.execute(
"INSERT INTO profile(id,first_name,life_goal,risk_profile) VALUES (1,?,?,?)",
(first_name, life_goal, risk_profile),
)
db.commit()
flash("Profile saved. Welcome aboard!")
return redirect(url_for("home"))
@app.route("/compare")
def compare():
brand = request.args.get("brand")
picked = brand if brand in BRANDS else None
coach_note = ai_coach("Give a short, friendly note about comparing a brand vs its suppliers.") if picked else ""
return render_template_string(
COMPARE_HTML,
app_name=APP_NAME,
title="Compare",
brands=BRANDS,
picked=picked,
get_price=get_price,
coach_note=coach_note,
)
@app.route("/watchlist/add", methods=["POST"])
def add_watchlist():
item_type = request.form.get("item_type")
name = (request.form.get("name") or "").strip()
if item_type not in {"brand", "supplier"} or not name:
flash("Invalid item.")
return redirect(request.referrer or url_for("home"))
with closing(get_db()) as db:
db.execute("INSERT INTO watchlist(item_type,name) VALUES(?,?)", (item_type, name))
db.commit()
flash("Added to watchlist.")
return redirect(request.referrer or url_for("home"))
@app.route("/watchlist/remove", methods=["POST"])
def remove_watchlist():
_id = request.form.get("id")
with closing(get_db()) as db:
db.execute("DELETE FROM watchlist WHERE id=?", (_id,))
db.commit()
flash("Removed from watchlist.")
return redirect(request.referrer or url_for("home"))
@app.route("/coach", methods=["GET", "POST"])
def coach():
answer = None
if request.method == "POST":
q = request.form.get("question", "")
answer = ai_coach(q)
return render_template_string(
COACH_HTML,
app_name=APP_NAME,
title="Coach",
answer=answer,
default_note=ai_coach(),
)
@app.route("/reset")
def reset():
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
init_db()
flash("Reset complete.")
return redirect(url_for("home"))
React demo route
@app.route("/react")
def react_demo():
return REACT_HTML
-------------------------
Template Loader
-------------------------
app.jinja_loader = DictLoader({
"base.html": BASE_HTML,
"home.html": HOME_HTML,
"compare.html": COMPARE_HTML,
"coach.html": COACH_HTML,
})
-------------------------
Run
-------------------------
if name == "main":
# Set host="0.0.0.0" if you want to access from other devices on your LAN
app.run(host="127.0.0.1", port=int(os.getenv("PORT", "5000")), debug=True)