From ddfbb046490aaaadce4542daa2b1238e278fe38f Mon Sep 17 00:00:00 2001 From: homelab-runtime-publisher Date: Sat, 1 Aug 2026 16:53:12 +0000 Subject: [PATCH] runtime: publish GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z --- ...C-V42-READ-ONLY-PLAN-20260801T145300Z.json | 38 ++++ ...DC-V42-READ-ONLY-PLAN-20260801T145300Z.txt | 34 ++++ runtime/latest.json | 39 +++- runtime/latest.txt | 182 +++--------------- 4 files changed, 136 insertions(+), 157 deletions(-) create mode 100644 runtime/history/GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z.json create mode 100644 runtime/history/GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z.txt diff --git a/runtime/history/GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z.json b/runtime/history/GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z.json new file mode 100644 index 0000000..ec73680 --- /dev/null +++ b/runtime/history/GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z.json @@ -0,0 +1,38 @@ +{ + "schema_version": 1, + "channel": "homelab-runtime", + "command_id": "GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z", + "status": "OK", + "rc": 0, + "host": "pve01", + "mode": "verify", + "component": "gram1-usdc-v42-plan", + "started_at_utc": "2026-08-01T16:53:11Z", + "finished_at_utc": "2026-08-01T16:53:12Z", + "reference_register_checked": true, + "reference_sha256": "5763f2f8edc75fcf6f3951f8c95896d1cc112ceccb6cf5b64a383f540a4deb8e", + "error_register_checked": true, + "error_register_sha256": "24934a2c7fa5c26f6e828583c4aefac7f143b40c0171736ed7571e617d55ab83", + "command_sha256": "4aa13a481b22ea81603d4ee7fc21459486180a7e1494918139d0491444affa0d", + "duplicate_failed_command_blocked": false, + "block_reason": null, + "execution_started": true, + "change_declared": false, + "result_contract_valid": true, + "result_contract_status": null, + "result_contract_error": null, + "command_rc": 0, + "changes_made": false, + "rollback_started": false, + "rollback_restored": null, + "mutation_outcome": "NO_MUTATION", + "sanitized": true, + "secrets_included": false, + "private_addresses_included": false, + "raw_evidence_retained_locally": true, + "raw_evidence_sha256": "c16acd7ab954591945c4217fb1164a9d843e977e059f5813af0a186f0e2eef0e", + "sanitized_output_sha256": "42e8083f48caed53cc9b54fb6257e82b09431702c60c32afbe615c365cfff23b", + "output_truncated_in_json": false, + "full_sanitized_output_url": "https://git.gram1.ru/.well-known/homelab-runtime/latest.txt", + "output": "HOMELAB_RESULT_CONTRACT={\"changes_made\":false,\"command_id\":\"GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z\",\"mutation_outcome\":\"NO_MUTATION\",\"rollback_restored\":null,\"rollback_started\":false,\"status\":\"OK\",\"version\":1}\n{\"changes_made\":false,\"command_id\":\"GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z\",\"control_plane_error\":null,\"gitea_changed\":false,\"mutation_outcome\":\"NO_MUTATION\",\"pinned_host_key_aliases\":[\"[PRIVATE_IP]\"],\"production_site_changed\":false,\"rc\":0,\"remote_result\":{\"active_container_hashes\":{\"web\":{\"rc\":0,\"stderr\":\"\",\"stdout\":\"3b55b78c015173eea66b67afb8fb9371f0d7d2e38a7679d4c433749f4c07301d /app/app.py\\n4a7b16ece826fe2d938f392db6b0b5ca028dcb6ff02fdff4ab431f114edf0e5c /app/worker.py\\n3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2 /app/templates/index.html\"},\"worker\":{\"rc\":0,\"stderr\":\"\",\"stdout\":\"ec226952cdb7fb58edbfc0173af47f4d901f3206e15169df12294983731fbc20 /app/app.py\\n4a7b16ece826fe2d938f392db6b0b5ca028dcb6ff02fdff4ab431f114edf0e5c /app/worker.py\\ne9e723c9b916383ce10dffe8f8bbdb4049b78d89c5b139e2de5ed82194a27be4 /app/templates/index.html\"}},\"app\":{\"assignments\":[{\"end_line\":10,\"line\":10,\"literal\":null,\"names\":[\"MARKET\"],\"source\":\"MARKET = DATA / \\\"market.json\\\"\"},{\"end_line\":13,\"line\":13,\"literal\":{\"avalanche-2\":\"AVAX\",\"chainlink\":\"LINK\",\"hedera-hashgraph\":\"HBAR\",\"polygon-ecosystem-token\":\"POL\"},\"names\":[\"COINS\"],\"source\":\"COINS = {\\\"hedera-hashgraph\\\": \\\"HBAR\\\", \\\"polygon-ecosystem-token\\\": \\\"POL\\\", \\\"avalanche-2\\\": \\\"AVAX\\\", \\\"chainlink\\\": \\\"LINK\\\"}\"},{\"end_line\":128,\"line\":128,\"literal\":{\"avalanche-2\":\"AVAX\",\"binancecoin\":\"BNB\",\"bitcoin\":\"₿\",\"cardano\":\"ADA\",\"chainlink\":\"LINK\",\"dogecoin\":\"Ð\",\"ethereum\":\"Ξ\",\"hedera\":\"HBAR\",\"hedera-hashgraph\":\"HBAR\",\"ripple\":\"XRP\",\"solana\":\"S\",\"toncoin\":\"TON\"},\"names\":[\"symbols\"],\"source\":\"symbols = {\\\"bitcoin\\\":\\\"₿\\\",\\\"ethereum\\\":\\\"Ξ\\\",\\\"solana\\\":\\\"S\\\",\\\"binancecoin\\\":\\\"BNB\\\",\\\"ripple\\\":\\\"XRP\\\",\\\"cardano\\\":\\\"ADA\\\",\\\"hedera-hashgraph\\\":\\\"HBAR\\\",\\\"hedera\\\":\\\"HBAR\\\",\\\"avalanche-2\\\":\\\"AVAX\\\",\\\"chainlink\\\":\\\"LINK\\\",\\\"toncoin\\\":\\\"TON\\\",\\\"dogecoin\\\":\\\"Ð\\\"}\"},{\"end_line\":139,\"line\":139,\"literal\":{\"AVAX\":\"AVAXUSDT\",\"HBAR\":\"HBARUSDT\",\"LINK\":\"LINKUSDT\",\"POL\":\"POLUSDT\"},\"names\":[\"KLINE_SYMBOLS\"],\"source\":\"KLINE_SYMBOLS = {\\\"HBAR\\\": \\\"HBARUSDT\\\", \\\"POL\\\": \\\"POLUSDT\\\", \\\"AVAX\\\": \\\"AVAXUSDT\\\", \\\"LINK\\\": \\\"LINKUSDT\\\"}\"},{\"end_line\":147,\"line\":147,\"literal\":{},\"names\":[\"KLINE_CACHE\"],\"source\":\"KLINE_CACHE = {}\"},{\"end_line\":148,\"line\":148,\"literal\":300,\"names\":[\"KLINE_CACHE_TTL\"],\"source\":\"KLINE_CACHE_TTL = 300\"},{\"end_line\":149,\"line\":149,\"literal\":604800,\"names\":[\"KLINE_CACHE_MAX_STALE\"],\"source\":\"KLINE_CACHE_MAX_STALE = 604800\"},{\"end_line\":150,\"line\":150,\"literal\":null,\"names\":[\"KLINE_CACHE_FILE\"],\"source\":\"KLINE_CACHE_FILE = DATA / \\\"klines-cache.json\\\"\"},{\"end_line\":151,\"line\":151,\"literal\":null,\"names\":[\"KLINE_CACHE_WRITE_LOCK\"],\"source\":\"KLINE_CACHE_WRITE_LOCK = threading.Lock()\"},{\"end_line\":350,\"line\":350,\"literal\":null,\"names\":[\"holdings\"],\"source\":\"holdings = {r[\\\"coin_id\\\"]: {\\\"quantity\\\":r[\\\"quantity\\\"],\\\"avg_buy\\\":r[\\\"avg_buy\\\"]} for r in c.execute(\\\"SELECT * FROM holdings\\\")}\"},{\"end_line\":351,\"line\":351,\"literal\":null,\"names\":[\"alerts\"],\"source\":\"alerts = [dict(r) for r in c.execute(\\\"SELECT * FROM alerts ORDER BY id DESC\\\")]\"},{\"end_line\":367,\"line\":367,\"literal\":null,\"names\":[\"coin\"],\"source\":\"coin = str(x.get(\\\"coin_id\\\",\\\"\\\"))\"},{\"end_line\":370,\"line\":370,\"literal\":null,\"names\":[\"quantity\"],\"source\":\"quantity = max(0.0, float(x.get(\\\"quantity\\\",0)))\"},{\"end_line\":371,\"line\":371,\"literal\":null,\"names\":[\"avg_buy\"],\"source\":\"avg_buy = max(0.0, float(x.get(\\\"avg_buy\\\",0)))\"},{\"end_line\":380,\"line\":380,\"literal\":null,\"names\":[\"coin\"],\"source\":\"coin = str(x.get(\\\"coin_id\\\",\\\"\\\"))\"},{\"end_line\":406,\"line\":406,\"literal\":\"\",\"names\":[\"_GRAM1_CHART_JS_B64\"],\"source\":\"_GRAM1_CHART_JS_B64 = \\\"\\\"\"},{\"end_line\":407,\"line\":407,\"literal\":[\"AVAX\",\"POL\",\"HBAR\",\"LINK\"],\"names\":[\"_GRAM1_CHART_SYMBOLS\"],\"source\":\"_GRAM1_CHART_SYMBOLS = {\\\"HBAR\\\", \\\"POL\\\", \\\"AVAX\\\", \\\"LINK\\\"}\"},{\"end_line\":459,\"line\":459,\"literal\":null,\"names\":[\"price\"],\"source\":\"price=hi-span*n/4\"},{\"end_line\":470,\"line\":470,\"literal\":null,\"names\":[\"symbol\"],\"source\":\"symbol=symbol.upper()\"}],\"relevant_functions\":[{\"end_line\":23,\"line\":16,\"name\":\"db\",\"source\":\"def db():\\n c = sqlite3.connect(DB, timeout=10)\\n c.row_factory = sqlite3.Row\\n c.execute(\\\"PRAGMA journal_mode=WAL\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\\\")\\n c.commit()\\n return c\"},{\"end_line\":47,\"line\":42,\"name\":\"market_data\",\"source\":\"def market_data():\\n try:\\n obj = json.loads(MARKET.read_text())\\n return obj if isinstance(obj, dict) else {}\\n except Exception:\\n return {}\"},{\"end_line\":56,\"line\":53,\"name\":\"login_page\",\"source\":\"def login_page(request: Request):\\n if request.session.get(\\\"auth\\\"):\\n return RedirectResponse(\\\"/\\\", 303)\\n return HTMLResponse(\\\"\\\"\\\"Gram Crypto

Gram Crypto

https://gram1.ru/

Введите пароль для доступа к криптопортфелю.

\\\"\\\"\\\" + (\\\"Неверный пароль\\\" if request.query_params.get(\\\"bad\\\") else \\\"\\\") + \\\"\\\"\\\"
\\\"\\\"\\\")\"},{\"end_line\":89,\"line\":85,\"name\":\"health\",\"source\":\"def health():\\n age = None\\n if MARKET.exists():\\n age = int(time.time() - MARKET.stat().st_mtime)\\n return {\\\"status\\\":\\\"OK\\\",\\\"type\\\":\\\"crypto-portfolio\\\",\\\"market_age_seconds\\\":age}\"},{\"end_line\":134,\"line\":93,\"name\":\"coin_icon\",\"source\":\"def coin_icon(coin_id: str):\\n import html\\n import os\\n import re\\n from pathlib import Path\\n import httpx\\n from fastapi import HTTPException\\n from fastapi.responses import Response\\n if not re.fullmatch(r\\\"[a-z0-9-]{1,64}\\\", coin_id):\\n raise HTTPException(status_code=404)\\n cache_dir = Path(\\\"/data/coin-icons\\\")\\n cache_dir.mkdir(parents=True, exist_ok=True)\\n body_path = cache_dir / (coin_id + \\\".img\\\")\\n type_path = cache_dir / (coin_id + \\\".type\\\")\\n if body_path.exists() and body_path.stat().st_size > 128:\\n media_type = type_path.read_text().strip() if type_path.exists() else \\\"image/png\\\"\\n return Response(body_path.read_bytes(), media_type=media_type, headers={\\\"Cache-Control\\\":\\\"public, max-age=86400\\\"})\\n try:\\n with httpx.Client(timeout=6, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as client:\\n response = client.get(\\\"https://api.coingecko.com/api/v3/coins/markets\\\", params={\\\"vs_currency\\\":\\\"usd\\\",\\\"ids\\\":coin_id,\\\"sparkline\\\":\\\"false\\\"})\\n response.raise_for_status()\\n rows = response.json()\\n if not rows or not rows[0].get(\\\"image\\\"):\\n raise ValueError(\\\"image URL missing\\\")\\n image = client.get(rows[0][\\\"image\\\"])\\n image.raise_for_status()\\n media_type = (image.headers.get(\\\"content-type\\\") or \\\"image/png\\\").split(\\\";\\\", 1)[0]\\n if not media_type.startswith(\\\"image/\\\") or len(image.content) <= 128:\\n raise ValueError(\\\"invalid image\\\")\\n tmp_path = body_path.with_suffix(\\\".tmp\\\")\\n tmp_path.write_bytes(image.content)\\n os.replace(tmp_path, body_path)\\n type_path.write_text(media_type)\\n return Response(image.content, media_type=media_type, headers={\\\"Cache-Control\\\":\\\"public, max-age=86400\\\"})\\n except Exception:\\n symbols = {\\\"bitcoin\\\":\\\"₿\\\",\\\"ethereum\\\":\\\"Ξ\\\",\\\"solana\\\":\\\"S\\\",\\\"binancecoin\\\":\\\"BNB\\\",\\\"ripple\\\":\\\"XRP\\\",\\\"cardano\\\":\\\"ADA\\\",\\\"hedera-hashgraph\\\":\\\"HBAR\\\",\\\"hedera\\\":\\\"HBAR\\\",\\\"avalanche-2\\\":\\\"AVAX\\\",\\\"chainlink\\\":\\\"LINK\\\",\\\"toncoin\\\":\\\"TON\\\",\\\"dogecoin\\\":\\\"Ð\\\"}\\n label = symbols.get(coin_id, coin_id[:3].upper())\\n palette = [\\\"#f7931a\\\",\\\"#627eea\\\",\\\"#14f195\\\",\\\"#f3ba2f\\\",\\\"#23292f\\\",\\\"#3468d4\\\",\\\"#6f5af5\\\"]\\n color = palette[sum(ord(ch) for ch in coin_id) % len(palette)]\\n safe_label = html.escape(label)\\n svg = f'= 2\\n )\"},{\"end_line\":183,\"line\":161,\"name\":\"_load_kline_cache\",\"source\":\"def _load_kline_cache():\\n if not KLINE_CACHE_FILE.is_file():\\n return\\n try:\\n payload = json.loads(KLINE_CACHE_FILE.read_text())\\n entries = payload.get(\\\"entries\\\", {})\\n now = time.time()\\n for name, item in entries.items():\\n parts = name.split(\\\":\\\")\\n if len(parts) == 3:\\n symbol, interval, limit_text = parts\\n elif len(parts) == 2:\\n symbol, limit_text = parts\\n interval = \\\"1d\\\"\\n else:\\n continue\\n limit = int(limit_text)\\n saved_at = float(item.get(\\\"saved_at\\\", 0))\\n result = item.get(\\\"result\\\")\\n if now - saved_at <= KLINE_CACHE_MAX_STALE and _valid_kline_result(result):\\n KLINE_CACHE[(symbol, interval, limit)] = (saved_at, result)\\n except Exception:\\n return\"},{\"end_line\":201,\"line\":185,\"name\":\"_save_kline_cache\",\"source\":\"def _save_kline_cache():\\n with KLINE_CACHE_WRITE_LOCK:\\n entries = {}\\n for key, value in list(KLINE_CACHE.items()):\\n if len(key) == 3:\\n symbol, interval, limit = key\\n else:\\n symbol, limit = key\\n interval = \\\"1d\\\"\\n saved_at, result = value\\n if _valid_kline_result(result):\\n entries[symbol + \\\":\\\" + interval + \\\":\\\" + str(limit)] = {\\\"saved_at\\\": saved_at, \\\"result\\\": result}\\n payload = {\\\"version\\\": 1, \\\"saved_at\\\": int(time.time()), \\\"entries\\\": entries}\\n temporary = KLINE_CACHE_FILE.with_suffix(\\\".json.tmp\\\")\\n temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(\\\",\\\", \\\":\\\")))\\n os.replace(temporary, KLINE_CACHE_FILE)\\n os.chmod(KLINE_CACHE_FILE, 0o644)\"},{\"end_line\":305,\"line\":205,\"name\":\"fetch_klines\",\"source\":\"def fetch_klines(symbol, limit=365, interval=\\\"1d\\\"):\\n key = str(symbol).upper()\\n pair = KLINE_SYMBOLS.get(key)\\n if not pair:\\n raise HTTPException(404, \\\"unknown symbol\\\")\\n interval = str(interval or \\\"1d\\\")\\n if interval not in {\\\"1d\\\", \\\"1M\\\"}:\\n raise HTTPException(400, \\\"unsupported interval\\\")\\n limit = max(1, min(int(limit), 1000))\\n if interval == \\\"1d\\\":\\n limit = max(30, limit)\\n cache_key = (key, interval, limit)\\n now = time.time()\\n cached = KLINE_CACHE.get(cache_key)\\n if cached and now - cached[0] < KLINE_CACHE_TTL:\\n result = dict(cached[1])\\n result[\\\"source\\\"] = \\\"cache\\\"\\n result[\\\"stale\\\"] = False\\n result[\\\"cache_age_seconds\\\"] = int(now - cached[0])\\n return result\\n stale = cached if cached and now - cached[0] <= KLINE_CACHE_MAX_STALE else None\\n remaining = limit\\n end_time = None\\n collected = []\\n try:\\n while remaining > 0:\\n page_limit = min(1000 if interval == \\\"1M\\\" else 100, remaining)\\n params = {\\\"symbol\\\": pair, \\\"interval\\\": interval, \\\"limit\\\": page_limit}\\n if end_time is not None:\\n params[\\\"endTime\\\"] = end_time\\n query = urllib.parse.urlencode(params)\\n request = urllib.request.Request(\\n \\\"https://data-api.binance.vision/api/v3/klines?\\\" + query,\\n headers={\\\"User-Agent\\\": \\\"crypto-portfolio/1.0\\\"},\\n )\\n rows = None\\n last_error = None\\n for attempt in range(3):\\n try:\\n with urllib.request.urlopen(request, timeout=20) as response:\\n rows = json.load(response)\\n break\\n except Exception as exc:\\n last_error = exc\\n if attempt < 2:\\n time.sleep(0.5 * (attempt + 1))\\n if rows is None:\\n raise last_error\\n if not isinstance(rows, list) or not rows:\\n break\\n collected.extend(rows)\\n remaining -= len(rows)\\n if len(rows) < page_limit:\\n break\\n end_time = int(rows[0][0]) - 1\\n except Exception as exc:\\n if stale:\\n result = dict(stale[1])\\n result[\\\"source\\\"] = \\\"stale-cache\\\"\\n result[\\\"stale\\\"] = True\\n result[\\\"cache_age_seconds\\\"] = int(now - stale[0])\\n \"},{\"end_line\":310,\"line\":308,\"name\":\"klines\",\"source\":\"def klines(symbol: str, request: Request, limit: int = 365, interval: str = \\\"1d\\\"):\\n require_login(request)\\n return fetch_klines(symbol, limit, interval)\"},{\"end_line\":344,\"line\":313,\"name\":\"chart_health\",\"source\":\"def chart_health():\\n now = time.time()\\n details = {}\\n bad = 0\\n for symbol in (\\\"HBAR\\\", \\\"POL\\\", \\\"AVAX\\\", \\\"LINK\\\"):\\n entry = KLINE_CACHE.get((symbol, \\\"1M\\\", 1000)) or KLINE_CACHE.get((symbol, \\\"1d\\\", 365))\\n if not entry or not _valid_kline_result(entry[1]):\\n details[symbol] = {\\\"status\\\": \\\"ERROR\\\", \\\"candles\\\": 0, \\\"cache_age_seconds\\\": None}\\n bad += 1\\n continue\\n age = int(now - entry[0])\\n expired = age > KLINE_CACHE_MAX_STALE\\n details[symbol] = {\\n \\\"status\\\": \\\"ERROR\\\" if expired else \\\"OK\\\",\\n \\\"candles\\\": len(entry[1][\\\"candles\\\"]),\\n \\\"cache_age_seconds\\\": age,\\n \\\"stale\\\": age > KLINE_CACHE_TTL,\\n \\\"source\\\": entry[1].get(\\\"source\\\", \\\"cache\\\"),\\n }\\n if expired:\\n bad += 1\\n library_ok = LIGHTWEIGHT_JS.is_file() and LIGHTWEIGHT_JS.stat().st_size > 100000\\n if not library_ok:\\n bad += 1\\n payload = {\\n \\\"status\\\": \\\"OK\\\" if bad == 0 else \\\"ERROR\\\",\\n \\\"library_ok\\\": library_ok,\\n \\\"disk_cache_present\\\": KLINE_CACHE_FILE.is_file(),\\n \\\"symbols\\\": details,\\n \\\"bad\\\": bad,\\n }\\n return JSONResponse(payload, status_code=200 if bad == 0 else 503)\"},{\"end_line\":361,\"line\":347,\"name\":\"state\",\"source\":\"def state(request: Request):\\n require_login(request)\\n with db() as c:\\n holdings = {r[\\\"coin_id\\\"]: {\\\"quantity\\\":r[\\\"quantity\\\"],\\\"avg_buy\\\":r[\\\"avg_buy\\\"]} for r in c.execute(\\\"SELECT * FROM holdings\\\")}\\n alerts = [dict(r) for r in c.execute(\\\"SELECT * FROM alerts ORDER BY id DESC\\\")]\\n history = {}\\n z = {r[\\\"name\\\"] for r in c.execute(\\\"PRAGMA table_info(market_history)\\\")}\\n cc = next((x for x in (\\\"coin_id\\\",\\\"coin\\\",\\\"asset_id\\\") if x in z),None); pc = next((x for x in (\\\"price\\\",\\\"current_price\\\",\\\"usd_price\\\",\\\"value\\\") if x in z),None); tc = next((x for x in (\\\"ts\\\",\\\"timestamp\\\",\\\"time\\\",\\\"created_at\\\",\\\"updated_at\\\") if x in z),None)\\n if cc and pc and tc:\\n q = f'SELECT \\\"{pc}\\\" p FROM market_history WHERE \\\"{cc}\\\"=? ORDER BY \\\"{tc}\\\"'\\n for cid in COINS:\\n v=[float(r[\\\"p\\\"]) for r in c.execute(q,(cid,)) if r[\\\"p\\\"] is not None]\\n history[cid]=v if len(v)<=1200 else [v[int(i*(len(v)-1)/1199)] for i in range(1200)]\\n # CRYPTO_HISTORY_STATE_V2\\n return {\\\"coins\\\":COINS,\\\"market\\\":market_data(),\\\"holdings\\\":holdings,\\\"alerts\\\":alerts,\\\"history\\\":history}\"},{\"end_line\":374,\"line\":364,\"name\":\"save_holding\",\"source\":\"async def save_holding(request: Request):\\n require_login(request)\\n x = await request.json()\\n coin = str(x.get(\\\"coin_id\\\",\\\"\\\"))\\n if coin not in COINS:\\n raise HTTPException(400,\\\"bad coin\\\")\\n quantity = max(0.0, float(x.get(\\\"quantity\\\",0)))\\n avg_buy = max(0.0, float(x.get(\\\"avg_buy\\\",0)))\\n with db() as c:\\n c.execute(\\\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\\\",(coin,quantity,avg_buy)); c.commit()\\n return {\\\"ok\\\":True}\"},{\"end_line\":388,\"line\":377,\"name\":\"add_alert\",\"source\":\"async def add_alert(request: Request):\\n require_login(request)\\n x = await request.json()\\n coin = str(x.get(\\\"coin_id\\\",\\\"\\\")); kind = str(x.get(\\\"kind\\\",\\\"\\\"))\\n if coin not in COINS or kind not in {\\\"price_above\\\",\\\"price_below\\\",\\\"cap_above\\\",\\\"cap_below\\\",\\\"pump\\\",\\\"dump\\\"}:\\n raise HTTPException(400,\\\"bad rule\\\")\\n threshold = float(x.get(\\\"threshold\\\",0)); window = int(x.get(\\\"window_seconds\\\",0)); email = str(x.get(\\\"email\\\",\\\"\\\")).strip()\\n if threshold <= 0 or \\\"@\\\" not in email or (kind in {\\\"pump\\\",\\\"dump\\\"} and window not in {300,900,1800,3600,14400,86400}):\\n raise HTTPException(400,\\\"bad values\\\")\\n with db() as c:\\n cur = c.execute(\\\"INSERT INTO alerts(coin_id,kind,threshold,window_seconds,email) VALUES(?,?,?,?,?)\\\",(coin,kind,threshold,window,email)); c.commit()\\n return {\\\"ok\\\":True,\\\"id\\\":cur.lastrowid}\"},{\"end_line\":395,\"line\":391,\"name\":\"delete_alert\",\"source\":\"def delete_alert(alert_id: int, request: Request):\\n require_login(request)\\n with db() as c:\\n c.execute(\\\"DELETE FROM alerts WHERE id=?\\\",(alert_id,)); c.commit()\\n return {\\\"ok\\\":True}\"},{\"end_line\":411,\"line\":410,\"name\":\"_gram1_chart_javascript_v4\",\"source\":\"def _gram1_chart_javascript_v4():\\n return _Gram1Response(content=_gram1_base64.b64decode(_GRAM1_CHART_JS_B64), media_type=\\\"application/javascript\\\", headers={\\\"Cache-Control\\\":\\\"no-store\\\"})\"},{\"end_line\":450,\"line\":413,\"name\":\"_gram1_chart_data_v4\",\"source\":\"async def _gram1_chart_data_v4(symbol: str, request: _Gram1Request):\\n fn = globals().get(\\\"klines\\\")\\n if fn is None:\\n raise RuntimeError(\\\"klines route function not found\\\")\\n kwargs = {}\\n for name, parameter in _gram1_inspect.signature(fn).parameters.items():\\n lname = name.lower()\\n if lname in {\\\"symbol\\\", \\\"coin\\\", \\\"ticker\\\"}: kwargs[name] = symbol\\n elif lname == \\\"request\\\": kwargs[name] = request\\n elif lname == \\\"limit\\\": kwargs[name] = 1000\\n elif lname == \\\"interval\\\": kwargs[name] = \\\"1M\\\"\\n elif parameter.default is _gram1_inspect.Parameter.empty:\\n raise RuntimeError(\\\"unsupported required klines parameter: \\\" + name)\\n value = fn(**kwargs)\\n if _gram1_inspect.isawaitable(value): value = await value\\n if hasattr(value, \\\"body\\\"):\\n value = _gram1_json.loads(bytes(value.body).decode(\\\"utf-8\\\"))\\n if not isinstance(value, dict):\\n raise RuntimeError(\\\"klines result is not an object\\\")\\n if value.get(\\\"status\\\") not in (None, \\\"OK\\\"):\\n raise RuntimeError(str(value.get(\\\"error\\\") or value.get(\\\"message\\\") or value.get(\\\"status\\\")))\\n candles = value.get(\\\"candles\\\")\\n if not isinstance(candles, list) and isinstance(value.get(\\\"result\\\"), dict): candles = value[\\\"result\\\"].get(\\\"candles\\\")\\n if not isinstance(candles, list) or not candles:\\n raise RuntimeError(\\\"klines returned no candles\\\")\\n rows = []\\n for item in candles:\\n try:\\n if isinstance(item, dict):\\n t=item.get(\\\"time\\\",item.get(\\\"open_time\\\",item.get(\\\"openTime\\\")));o=float(item[\\\"open\\\"]);h=float(item[\\\"high\\\"]);l=float(item[\\\"low\\\"]);c=float(item[\\\"close\\\"])\\n else:\\n t=item[0];o=float(item[1]);h=float(item[2]);l=float(item[3]);c=float(item[4])\\n if h < l: continue\\n rows.append((str(t),o,h,l,c))\\n except Exception:\\n continue\\n if not rows: raise RuntimeError(\\\"no valid OHLC rows\\\")\\n return rows[-180:]\"},{\"end_line\":464,\"line\":452,\"name\":\"_gram1_chart_svg_v4\",\"source\":\"def _gram1_chart_svg_v4(symbol: str, rows):\\n width,height,left,right,top,bottom=1200,430,72,28,24,42\\n lo=min(r[3] for r in rows);hi=max(r[2] for r in rows);span=max(hi-lo,abs(hi)*1e-9,1e-12)\\n plot_w=width-left-right;plot_h=height-top-bottom;step=plot_w/max(len(rows),1);body=max(2.0,min(10.0,step*0.58))\\n def y(v): return top+(hi-v)/span*plot_h\\n parts=[f'', '']\\n for n in range(5):\\n yy=top+plot_h*n/4;price=hi-span*n/4;parts.append(f'');parts.append(f'{price:.8g}')\\n for i,(_,o,h,l,c) in enumerate(rows):\\n x=left+step*(i+.5);color=\\\"#63e6be\\\" if c>=o else \\\"#ff6b81\\\";yo=y(o);yc=y(c);yh=y(h);yl=y(l);rh=max(1.2,abs(yc-yo));ry=min(yo,yc)\\n parts.append(f'');parts.append(f'')\\n parts.append(f'{_gram1_html.escape(symbol)} / USDT · 1M · {len(rows)} свечей')\\n return ''.join(parts)\"},{\"end_line\":480,\"line\":467,\"name\":\"_gram1_chart_svg_endpoint_v4\",\"source\":\"async def _gram1_chart_svg_endpoint_v4(symbol: str, request: _Gram1Request):\\n if not request.session.get(\\\"auth\\\"):\\n return _Gram1Response(content=\\\"Требуется вход\\\", status_code=401, media_type=\\\"text/plain; charset=utf-8\\\")\\n symbol=symbol.upper()\\n if symbol not in _GRAM1_CHART_SYMBOLS:\\n return _Gram1Response(content=\\\"Неизвестная монета\\\", status_code=404, media_type=\\\"text/plain; charset=utf-8\\\")\\n try:\\n rows=await _gram1_chart_data_v4(symbol,request)\\n svg=_gram1_chart_svg_v4(symbol,rows)\\n return _Gram1Response(content=svg,media_type=\\\"image/svg+xml\\\",headers={\\\"Cache-Control\\\":\\\"no-store\\\"})\\n except Exception as exc:\\n msg=_gram1_html.escape(str(exc))\\n svg=f'Ошибка серверного графика {symbol}: {msg}'\\n return _Gram1Response(content=svg,status_code=503,media_type=\\\"image/svg+xml\\\",headers={\\\"Cache-Control\\\":\\\"no-store\\\"})\"}],\"routes\":[{\"function\":\"login_page\",\"line\":53,\"routes\":[{\"method\":\"GET\",\"path\":\"/login\"}]},{\"function\":\"login\",\"line\":59,\"routes\":[{\"method\":\"POST\",\"path\":\"/login\"}]},{\"function\":\"logout\",\"line\":74,\"routes\":[{\"method\":\"POST\",\"path\":\"/logout\"}]},{\"function\":\"home\",\"line\":79,\"routes\":[{\"method\":\"GET\",\"path\":\"/\"}]},{\"function\":\"health\",\"line\":85,\"routes\":[{\"method\":\"GET\",\"path\":\"/health\"}]},{\"function\":\"coin_icon\",\"line\":93,\"routes\":[{\"method\":\"GET\",\"path\":\"/coin-icon/{coin_id}\"}]},{\"function\":\"lightweight_charts_js\",\"line\":142,\"routes\":[{\"method\":\"GET\",\"path\":\"/vendor/lightweight-charts.js\"}]},{\"function\":\"klines\",\"line\":308,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/klines/{symbol}\"}]},{\"function\":\"chart_health\",\"line\":313,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/chart-health\"}]},{\"function\":\"state\",\"line\":347,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/state\"}]},{\"function\":\"save_holding\",\"line\":364,\"routes\":[{\"method\":\"POST\",\"path\":\"/api/holding\"}]},{\"function\":\"add_alert\",\"line\":377,\"routes\":[{\"method\":\"POST\",\"path\":\"/api/alert\"}]},{\"function\":\"delete_alert\",\"line\":391,\"routes\":[{\"method\":\"DELETE\",\"path\":\"/api/alert/{alert_id}\"}]},{\"function\":\"_gram1_chart_javascript_v4\",\"line\":410,\"routes\":[{\"method\":\"GET\",\"path\":\"/vendor/gram1-charts-v4.js\"}]},{\"function\":\"_gram1_chart_svg_endpoint_v4\",\"line\":467,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/chart-svg/{symbol}\"}]}]},\"changes_made\":false,\"command_id\":\"GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z\",\"compose\":{\"compose_file\":\"compose.yaml\",\"config_rc\":0,\"ps_excerpt\":\"{\\\"Command\\\":\\\"\\\\\\\"uvicorn app:app --h…\\\\\\\"\\\",\\\"CreatedAt\\\":\\\"2026-08-01 16:21:30 +0300 MSK\\\",\\\"ExitCode\\\":0,\\\"Health\\\":\\\"\\\",\\\"ID\\\":\\\"dc202c12ba81\\\",\\\"Image\\\":\\\"crypto-portfolio-web\\\",\\\"Labels\\\":\\\"com.docker.compose.depends_on=,com.docker.compose.image=sha256:2a9339d475b2f4a01f060162f48a23b9590eb3ba4b036b46aad72adad540c40d,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=web,com.docker.compose.,com.docker.compose.oneoff=False,com.docker.compose.project=crypto-portfolio,com.docker.compose.version=2.26.1,com.docker.compose.container-number=1\\\",\\\"LocalVolumes\\\":\\\"0\\\",\\\"Mounts\\\":\\\"/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…\\\",\\\"Name\\\":\\\"crypto-portfolio\\\",\\\"Names\\\":\\\"crypto-portfolio\\\",\\\"Networks\\\":\\\"crypto-portfolio_default\\\",\\\"Ports\\\":\\\":8030-\\\\u003e8000/tcp\\\",\\\"Project\\\":\\\"crypto-portfolio\\\",\\\"Publishers\\\":[{\\\"URL\\\":\\\"\\\",\\\"TargetPort\\\":8000,\\\"PublishedPort\\\":8030,\\\"Protocol\\\":\\\"tcp\\\"}],\\\"RunningFor\\\":\\\"4 hours ago\\\",\\\"Service\\\":\\\"web\\\",\\\"Size\\\":\\\"0B\\\",\\\"State\\\":\\\"running\\\",\\\"Status\\\":\\\"Up 4 hours\\\"}\\n{\\\"Command\\\":\\\"\\\\\\\"python /app/worker.…\\\\\\\"\\\",\\\"CreatedAt\\\":\\\"2026-08-01 13:56:57 +0300 MSK\\\",\\\"ExitCode\\\":0,\\\"Health\\\":\\\"\\\",\\\"ID\\\":\\\"9a2ac999b94a\\\",\\\"Image\\\":\\\"crypto-portfolio-worker\\\",\\\"Labels\\\":\\\"com.docker.compose.project=crypto-portfolio,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.version=2.26.1,com.docker.compose.image=sha256:7ff5fdff7ab373e6b22dfa1a677b000d83ff9d021e4464915078e506781fd0ed,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=worker,com.docker.compose.,com.docker.compose.container-number=1,com.docker.compose.depends_on=,com.docker.compose.oneoff=False\\\",\\\"LocalVolumes\\\":\\\"0\\\",\\\"Mounts\\\":\\\"/opt/stacks/cr…,/opt/stacks/cr…\\\",\\\"Name\\\":\\\"crypto-portfolio-worker\\\",\\\"Names\\\":\\\"crypto-portfolio-worker\\\",\\\"Networks\\\":\\\"crypto-portfolio_default\\\",\\\"Ports\\\":\\\"\\\",\\\"Project\\\":\\\"crypto-portfolio\\\",\\\"Publishers\\\":null,\\\"RunningFor\\\":\\\"6 hours ago\\\",\\\"Service\\\":\\\"worker\\\",\\\"Size\\\":\\\"0B\\\",\\\"State\\\":\\\"running\\\",\\\"Status\\\":\\\"Up 6 hours\\\"}\",\"ps_rc\":0,\"services\":[\"web\",\"worker\"],\"services_rc\":0,\"stderr\":\"\"},\"control_plane_error\":null,\"duration_seconds\":0.043,\"files\":[{\"bytes\":32843,\"exists\":true,\"gid\":0,\"mode\":\"0o640\",\"path\":\"app.py\",\"sha256\":\"3b55b78c015173eea66b67afb8fb9371f0d7d2e38a7679d4c433749f4c07301d\",\"uid\":0},{\"bytes\":10300,\"exists\":true,\"gid\":0,\"mode\":\"0o640\",\"path\":\"worker.py\",\"sha256\":\"4a7b16ece826fe2d938f392db6b0b5ca028dcb6ff02fdff4ab431f114edf0e5c\",\"uid\":0},{\"bytes\":38036,\"exists\":true,\"gid\":0,\"mode\":\"0o644\",\"path\":\"templates/index.html\",\"sha256\":\"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2\",\"uid\":0},{\"bytes\":1233,\"exists\":true,\"gid\":0,\"mode\":\"0o644\",\"path\":\"compose.yaml\",\"sha256\":\"6d344e46b3de94ad86131a914df2ff3ff25e9d82f7ce0557cbffa468b8cdc1b8\",\"uid\":0}],\"gitea_changed\":false,\"hostname\":\"core-apps\",\"mutation_outcome\":\"NO_MUTATION\",\"plan\":{\"current_usdc_present_anywhere\":false,\"market_sections_found\":{\"alerts\":true,\"charts\":true,\"sharp_moves\":true,\"top_coin_cards\":true},\"mutation_scope_candidate\":[\"app.py\",\"templates/index.html\"],\"next_step\":\"ONE_ATOMIC_REVERSIBLE_MUTATION_AFTER_REVIEW_OF_THIS_EXACT_REPORT\",\"notes\":[\"Do not add USDC to the shared market COINS/KLINE/alert lists.\",\"Preserve HBAR/POL/AVAX/LINK market behavior unchanged.\",\"Expose an editable USDC quantity and average purchase price only if the current positions UI already uses those inputs.\",\"Portfolio totals must count USDC current value and cost basis; P/L for USDC follows the same row formula.\"],\"positions_section_found\":true,\"quantity_or_cost_basis_storage_found\":true,\"requested_behavior\":{\"add_usdc_to_positions_only\":true,\"exclude_from_alerts\":true,\"exclude_from_charts\":true,\"exclude_from_sharp_moves\":true,\"exclude_from_top_coin_cards\":true,\"include_usdc_in_total_invested\":true,\"include_usdc_in_total_portfolio_value\":true,\"usdc_market_price_policy\":\"fixed_1_usd_unless_existing_source_is_explicitly_better\"},\"required_architecture\":\"separate portfolio-position asset set from market-tracked asset set; USDC must exist only in the former\",\"worker_change_expected\":false},\"production_site_changed\":false,\"rc\":0,\"relevant_file_hits\":[{\"line\":9,\"path\":\"app.py\",\"text\":\"DB = DATA / \\\"portfolio.db\\\"\"},{\"line\":20,\"path\":\"app.py\",\"text\":\"c.execute(\\\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\\\")\"},{\"line\":89,\"path\":\"app.py\",\"text\":\"return {\\\"status\\\":\\\"OK\\\",\\\"type\\\":\\\"crypto-portfolio\\\",\\\"market_age_seconds\\\":age}\"},{\"line\":111,\"path\":\"app.py\",\"text\":\"with httpx.Client(timeout=6, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as client:\"},{\"line\":238,\"path\":\"app.py\",\"text\":\"headers={\\\"User-Agent\\\": \\\"crypto-portfolio/1.0\\\"},\"},{\"line\":350,\"path\":\"app.py\",\"text\":\"holdings = {r[\\\"coin_id\\\"]: {\\\"quantity\\\":r[\\\"quantity\\\"],\\\"avg_buy\\\":r[\\\"avg_buy\\\"]} for r in c.execute(\\\"SELECT * FROM holdings\\\")}\"},{\"line\":361,\"path\":\"app.py\",\"text\":\"return {\\\"coins\\\":COINS,\\\"market\\\":market_data(),\\\"holdings\\\":holdings,\\\"alerts\\\":alerts,\\\"history\\\":history}\"},{\"line\":363,\"path\":\"app.py\",\"text\":\"@app.post(\\\"/api/holding\\\")\"},{\"line\":364,\"path\":\"app.py\",\"text\":\"async def save_holding(request: Request):\"},{\"line\":370,\"path\":\"app.py\",\"text\":\"quantity = max(0.0, float(x.get(\\\"quantity\\\",0)))\"},{\"line\":373,\"path\":\"app.py\",\"text\":\"c.execute(\\\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\\\",(coin,quantity,avg_buy)); c.commit()\"},{\"line\":4,\"path\":\"compose.yaml\",\"text\":\"container_name: crypto-portfolio\"},{\"line\":29,\"path\":\"compose.yaml\",\"text\":\"container_name: crypto-portfolio-worker\"},{\"line\":7,\"path\":\"data/lightweight-charts.standalone.production.js\",\"text\":\"!function(){\\\"use strict\\\";const t={title:\\\"\\\",visible:!0,hitTestTolerance:3,lastValueVisible:!0,priceLineVisible:!0,priceLineSource:0,priceLineWidth:1,priceLineColor:\\\"\\\",priceLineStyle:2,baseLineVisible:!0,baseLineWidth:1,baseLineColor:\\\"#B2B5BE\\\",baseLineStyle:0,priceFormat:{type:\\\"price\\\",precision:2,minMove:.01}};var i,n;function s(t,i){const n=function(t,i){switch(t){case 0:default:return[];case 1:return[i,i];case 2:return[2*i,2*i];case 3:return[6*i,6*i];case 4:return[i,4*i]}}(i,t.lineWidth);return t.setLineDash(n),n}function e(t,i,n,s){t.beginPath();const e=t.lineWidth%2?.5:0;t.moveTo(n,i+e),t.lineTo(s,i+e),t.stroke()}function r(t,i){if(!t)throw new Error(\\\"Assertion failed\\\"+(i?\\\": \\\"+i:\\\"\\\"))}function h(t){if(void 0===t)throw new Error(\\\"Value is undefined\\\");return t}function a(t){if(null===t)throw new Error(\\\"Value is null\\\");return t}function l(t){return a(h(t))}!function(t){t[t.Simple=0]=\\\"Simple\\\",t[t.WithSteps=1]=\\\"WithSteps\\\",t[t.Curved=2]=\\\"Curved\\\"}(i||(i={})),function(t){t[t.Solid=0]=\\\"Solid\\\",\"},{\"line\":3,\"path\":\"templates/index.html\",\"text\":\"
Стоимость портфеля
Вложено
Прибыль / убыток0%
Обновление рынкакаждые 5 минут
\"},{\"line\":9,\"path\":\"templates/index.html\",\"text\":\"\"},{\"line\":109,\"text\":\"\"},{\"line\":217,\"text\":\"\"},{\"line\":231,\"text\":\"\"},{\"line\":232,\"text\":\"\"}],\"sha256\":\"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2\"},\"worker\":{\"assignments\":[{\"end_line\":8,\"line\":8,\"literal\":null,\"names\":[\"MARKET\"],\"source\":\"MARKET = DATA / \\\"market.json\\\"\"},{\"end_line\":11,\"line\":11,\"literal\":{\"avalanche-2\":\"AVAX\",\"chainlink\":\"LINK\",\"hedera-hashgraph\":\"HBAR\",\"polygon-ecosystem-token\":\"POL\"},\"names\":[\"COINS\"],\"source\":\"COINS = {\\\"hedera-hashgraph\\\": \\\"HBAR\\\", \\\"polygon-ecosystem-token\\\": \\\"POL\\\", \\\"avalanche-2\\\": \\\"AVAX\\\", \\\"chainlink\\\": \\\"LINK\\\"}\"},{\"end_line\":111,\"line\":111,\"literal\":null,\"names\":[\"quantity\"],\"source\":\"quantity = float(r[\\\"quantity\\\"])\"},{\"end_line\":112,\"line\":112,\"literal\":null,\"names\":[\"avg\"],\"source\":\"avg = float(r[\\\"avg_buy\\\"])\"},{\"end_line\":114,\"line\":114,\"literal\":null,\"names\":[\"invested\"],\"source\":\"invested = quantity * avg\"},{\"end_line\":123,\"line\":123,\"literal\":null,\"names\":[\"coin\"],\"source\":\"coin = market[\\\"coins\\\"].get(a[\\\"coin_id\\\"])\"},{\"end_line\":128,\"line\":128,\"literal\":null,\"names\":[\"price\"],\"source\":\"price = float(coin[\\\"price\\\"])\"},{\"end_line\":179,\"line\":179,\"literal\":null,\"names\":[\"market\"],\"source\":\"market = fetch_market()\"}],\"relevant_functions\":[{\"end_line\":23,\"line\":14,\"name\":\"connect\",\"source\":\"def connect():\\n c = sqlite3.connect(DB, timeout=15)\\n c.row_factory = sqlite3.Row\\n c.execute(\\\"PRAGMA journal_mode=WAL\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS market_history(ts INTEGER NOT NULL, coin_id TEXT NOT NULL, price REAL NOT NULL, market_cap REAL NOT NULL, volume REAL NOT NULL DEFAULT 0, PRIMARY KEY(ts,coin_id))\\\")\\n c.execute(\\\"CREATE INDEX IF NOT EXISTS idx_market_history_coin_ts ON market_history(coin_id,ts)\\\")\\n c.commit()\\n return c\"},{\"end_line\":75,\"line\":41,\"name\":\"fetch_market\",\"source\":\"def fetch_market():\\n params = {\\\"vs_currency\\\":\\\"usd\\\",\\\"ids\\\":\\\",\\\".join(COINS),\\\"order\\\":\\\"market_cap_desc\\\",\\\"per_page\\\":50,\\\"page\\\":1,\\\"sparkline\\\":\\\"true\\\",\\\"price_change_percentage\\\":\\\"1h,24h,7d\\\"}\\n with httpx.Client(timeout=25, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as client:\\n r = client.get(\\\"https://api.coingecko.com/api/v3/coins/markets\\\", params=params)\\n r.raise_for_status()\\n rows = r.json()\\n now = int(time.time())\\n usd_rub = 0.0\\n try:\\n with open(MARKET, \\\"r\\\", encoding=\\\"utf-8\\\") as previous_file:\\n previous_rate = float((json.load(previous_file) or {}).get(\\\"usd_rub\\\") or 0)\\n if 40 <= previous_rate <= 200:\\n usd_rub = previous_rate\\n except Exception:\\n pass\\n try:\\n with httpx.Client(timeout=15, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as fx_client:\\n fx_data = fx_client.get(\\\"https://api.coingecko.com/api/v3/simple/price\\\", params={\\\"ids\\\":\\\"bitcoin\\\",\\\"vs_currencies\\\":\\\"usd,rub\\\"}).json()[\\\"bitcoin\\\"]\\n candidate = float(fx_data[\\\"rub\\\"]) / float(fx_data[\\\"usd\\\"])\\n if 40 <= candidate <= 200:\\n usd_rub = candidate\\n except Exception:\\n pass\\n if not 40 <= usd_rub <= 200:\\n usd_rub = 77.025093\\n result = {\\\"updated_at\\\":now,\\\"currency\\\":\\\"usd\\\",\\\"usd_rub\\\":usd_rub,\\\"coins\\\":{}}\\n\\n for x in rows:\\n cid = str(x.get(\\\"id\\\",\\\"\\\"))\\n if cid not in COINS:\\n continue\\n result[\\\"coins\\\"][cid] = {\\\"symbol\\\":COINS[cid],\\\"name\\\":x.get(\\\"name\\\"),\\\"price\\\":float(x.get(\\\"current_price\\\") or 0),\\\"market_cap\\\":float(x.get(\\\"market_cap\\\") or 0),\\\"rank\\\":x.get(\\\"market_cap_rank\\\"),\\\"volume\\\":float(x.get(\\\"total_volume\\\") or 0),\\\"high_24h\\\":x.get(\\\"high_24h\\\"),\\\"low_24h\\\":x.get(\\\"low_24h\\\"),\\\"change_1h\\\":x.get(\\\"price_change_percentage_1h_in_currency\\\"),\\\"change_24h\\\":x.get(\\\"price_change_percentage_24h_in_currency\\\"),\\\"change_7d\\\":x.get(\\\"price_change_percentage_7d_in_currency\\\"),\\\"sparkline_7d\\\":((x.get(\\\"sparkline_in_7d\\\") or {}).get(\\\"price\\\") or []),\\\"last_updated\\\":x.get(\\\"last_updated\\\")}\\n if len(result[\\\"coins\\\"]) != len(COINS):\\n raise RuntimeError(\\\"incomplete market response\\\")\\n return result\"},{\"end_line\":79,\"line\":77,\"name\":\"reference_price\",\"source\":\"def reference_price(c, coin_id, target_ts):\\n r = c.execute(\\\"SELECT price FROM market_history WHERE coin_id=? AND ts<=? ORDER BY ts DESC LIMIT 1\\\",(coin_id,target_ts)).fetchone()\\n return float(r[\\\"price\\\"]) if r else None\"},{\"end_line\":117,\"line\":107,\"name\":\"portfolio_text\",\"source\":\"def portfolio_text(c, coin_id, price):\\n r = c.execute(\\\"SELECT quantity,avg_buy FROM holdings WHERE coin_id=?\\\",(coin_id,)).fetchone()\\n if not r:\\n return \\\"\\\"\\n quantity = float(r[\\\"quantity\\\"])\\n avg = float(r[\\\"avg_buy\\\"])\\n value = quantity * price\\n invested = quantity * avg\\n pnl = value - invested\\n pct = pnl / invested * 100 if invested else 0\\n return \\\"\\\\nКоличество монет: {:.8f}\\\\nТекущая стоимость позиции: ${:,.2f}\\\\nПрибыль или убыток: ${:,.2f} ({:+.2f}%)\\\\n\\\".format(quantity,value,pnl,pct)\"},{\"end_line\":175,\"line\":119,\"name\":\"evaluate\",\"source\":\"def evaluate(c, market):\\n now = int(time.time())\\n triggered = 0\\n for a in c.execute(\\\"SELECT * FROM alerts WHERE enabled=1 ORDER BY id\\\").fetchall():\\n coin = market[\\\"coins\\\"].get(a[\\\"coin_id\\\"])\\n if not coin:\\n continue\\n kind = a[\\\"kind\\\"]\\n threshold = float(a[\\\"threshold\\\"])\\n price = float(coin[\\\"price\\\"])\\n cap = float(coin[\\\"market_cap\\\"])\\n latched = int(a[\\\"latched\\\"])\\n fired = False\\n reset = False\\n description = \\\"\\\"\\n if kind == \\\"price_above\\\":\\n fired = price >= threshold\\n reset = price < threshold * 0.99\\n description = \\\"Цена {} достигла ${:,.8f}, порог выше или равно ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],price,threshold)\\n elif kind == \\\"price_below\\\":\\n fired = price <= threshold\\n reset = price > threshold * 1.01\\n description = \\\"Цена {} снизилась до ${:,.8f}, порог ниже или равно ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],price,threshold)\\n elif kind == \\\"cap_above\\\":\\n fired = cap >= threshold\\n reset = cap < threshold * 0.99\\n description = \\\"Капитализация {} достигла ${:,.0f}, порог выше или равно ${:,.0f}.\\\".format(coin[\\\"symbol\\\"],cap,threshold)\\n elif kind == \\\"cap_below\\\":\\n fired = cap <= threshold\\n reset = cap > threshold * 1.01\\n description = \\\"Капитализация {} снизилась до ${:,.0f}, порог ниже или равно ${:,.0f}.\\\".format(coin[\\\"symbol\\\"],cap,threshold)\\n elif kind in {\\\"pump\\\",\\\"dump\\\"}:\\n old = reference_price(c,a[\\\"coin_id\\\"],now-int(a[\\\"window_seconds\\\"]))\\n if old and old > 0:\\n change = (price / old - 1) * 100\\n if kind == \\\"pump\\\":\\n fired = change >= threshold\\n reset = change < threshold * 0.5\\n description = \\\"Резкий памп {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],change,int(a[\\\"window_seconds\\\"])//60,old,price)\\n else:\\n fired = change <= -threshold\\n reset = change > -threshold * 0.5\\n description = \\\"Резкое падение {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],change,int(a[\\\"window_seconds\\\"])//60,old,price)\\n if reset and latched:\\n c.execute(\\\"UPDATE alerts SET latched=0 WHERE id=?\\\",\"},{\"end_line\":190,\"line\":177,\"name\":\"cycle\",\"source\":\"def cycle():\\n DATA.mkdir(parents=True, exist_ok=True)\\n market = fetch_market()\\n now = int(market[\\\"updated_at\\\"])\\n with connect() as c:\\n for cid, x in market[\\\"coins\\\"].items():\\n c.execute(\\\"INSERT OR REPLACE INTO market_history(ts,coin_id,price,market_cap,volume) VALUES(?,?,?,?,?)\\\",(now,cid,x[\\\"price\\\"],x[\\\"market_cap\\\"],x[\\\"volume\\\"]))\\n c.execute(\\\"DELETE FROM market_history WHERE ts","names":["_GRAM1_CHART_JS_B64"],"source":"_GRAM1_CHART_JS_B64 = \"\""},{"end_line":407,"line":407,"literal":["AVAX","POL","HBAR","LINK"],"names":["_GRAM1_CHART_SYMBOLS"],"source":"_GRAM1_CHART_SYMBOLS = {\"HBAR\", \"POL\", \"AVAX\", \"LINK\"}"},{"end_line":459,"line":459,"literal":null,"names":["price"],"source":"price=hi-span*n/4"},{"end_line":470,"line":470,"literal":null,"names":["symbol"],"source":"symbol=symbol.upper()"}],"relevant_functions":[{"end_line":23,"line":16,"name":"db","source":"def db():\n c = sqlite3.connect(DB, timeout=10)\n c.row_factory = sqlite3.Row\n c.execute(\"PRAGMA journal_mode=WAL\")\n c.execute(\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\")\n c.execute(\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\")\n c.commit()\n return c"},{"end_line":47,"line":42,"name":"market_data","source":"def market_data():\n try:\n obj = json.loads(MARKET.read_text())\n return obj if isinstance(obj, dict) else {}\n except Exception:\n return {}"},{"end_line":56,"line":53,"name":"login_page","source":"def login_page(request: Request):\n if request.session.get(\"auth\"):\n return RedirectResponse(\"/\", 303)\n return HTMLResponse(\"\"\"Gram Crypto

Gram Crypto

https://gram1.ru/

Введите пароль для доступа к криптопортфелю.

\"\"\" + (\"Неверный пароль\" if request.query_params.get(\"bad\") else \"\") + \"\"\"
\"\"\")"},{"end_line":89,"line":85,"name":"health","source":"def health():\n age = None\n if MARKET.exists():\n age = int(time.time() - MARKET.stat().st_mtime)\n return {\"status\":\"OK\",\"type\":\"crypto-portfolio\",\"market_age_seconds\":age}"},{"end_line":134,"line":93,"name":"coin_icon","source":"def coin_icon(coin_id: str):\n import html\n import os\n import re\n from pathlib import Path\n import httpx\n from fastapi import HTTPException\n from fastapi.responses import Response\n if not re.fullmatch(r\"[a-z0-9-]{1,64}\", coin_id):\n raise HTTPException(status_code=404)\n cache_dir = Path(\"/data/coin-icons\")\n cache_dir.mkdir(parents=True, exist_ok=True)\n body_path = cache_dir / (coin_id + \".img\")\n type_path = cache_dir / (coin_id + \".type\")\n if body_path.exists() and body_path.stat().st_size > 128:\n media_type = type_path.read_text().strip() if type_path.exists() else \"image/png\"\n return Response(body_path.read_bytes(), media_type=media_type, headers={\"Cache-Control\":\"public, max-age=86400\"})\n try:\n with httpx.Client(timeout=6, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as client:\n response = client.get(\"https://api.coingecko.com/api/v3/coins/markets\", params={\"vs_currency\":\"usd\",\"ids\":coin_id,\"sparkline\":\"false\"})\n response.raise_for_status()\n rows = response.json()\n if not rows or not rows[0].get(\"image\"):\n raise ValueError(\"image URL missing\")\n image = client.get(rows[0][\"image\"])\n image.raise_for_status()\n media_type = (image.headers.get(\"content-type\") or \"image/png\").split(\";\", 1)[0]\n if not media_type.startswith(\"image/\") or len(image.content) <= 128:\n raise ValueError(\"invalid image\")\n tmp_path = body_path.with_suffix(\".tmp\")\n tmp_path.write_bytes(image.content)\n os.replace(tmp_path, body_path)\n type_path.write_text(media_type)\n return Response(image.content, media_type=media_type, headers={\"Cache-Control\":\"public, max-age=86400\"})\n except Exception:\n symbols = {\"bitcoin\":\"₿\",\"ethereum\":\"Ξ\",\"solana\":\"S\",\"binancecoin\":\"BNB\",\"ripple\":\"XRP\",\"cardano\":\"ADA\",\"hedera-hashgraph\":\"HBAR\",\"hedera\":\"HBAR\",\"avalanche-2\":\"AVAX\",\"chainlink\":\"LINK\",\"toncoin\":\"TON\",\"dogecoin\":\"Ð\"}\n label = symbols.get(coin_id, coin_id[:3].upper())\n palette = [\"#f7931a\",\"#627eea\",\"#14f195\",\"#f3ba2f\",\"#23292f\",\"#3468d4\",\"#6f5af5\"]\n color = palette[sum(ord(ch) for ch in coin_id) % len(palette)]\n safe_label = html.escape(label)\n svg = f'= 2\n )"},{"end_line":183,"line":161,"name":"_load_kline_cache","source":"def _load_kline_cache():\n if not KLINE_CACHE_FILE.is_file():\n return\n try:\n payload = json.loads(KLINE_CACHE_FILE.read_text())\n entries = payload.get(\"entries\", {})\n now = time.time()\n for name, item in entries.items():\n parts = name.split(\":\")\n if len(parts) == 3:\n symbol, interval, limit_text = parts\n elif len(parts) == 2:\n symbol, limit_text = parts\n interval = \"1d\"\n else:\n continue\n limit = int(limit_text)\n saved_at = float(item.get(\"saved_at\", 0))\n result = item.get(\"result\")\n if now - saved_at <= KLINE_CACHE_MAX_STALE and _valid_kline_result(result):\n KLINE_CACHE[(symbol, interval, limit)] = (saved_at, result)\n except Exception:\n return"},{"end_line":201,"line":185,"name":"_save_kline_cache","source":"def _save_kline_cache():\n with KLINE_CACHE_WRITE_LOCK:\n entries = {}\n for key, value in list(KLINE_CACHE.items()):\n if len(key) == 3:\n symbol, interval, limit = key\n else:\n symbol, limit = key\n interval = \"1d\"\n saved_at, result = value\n if _valid_kline_result(result):\n entries[symbol + \":\" + interval + \":\" + str(limit)] = {\"saved_at\": saved_at, \"result\": result}\n payload = {\"version\": 1, \"saved_at\": int(time.time()), \"entries\": entries}\n temporary = KLINE_CACHE_FILE.with_suffix(\".json.tmp\")\n temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(\",\", \":\")))\n os.replace(temporary, KLINE_CACHE_FILE)\n os.chmod(KLINE_CACHE_FILE, 0o644)"},{"end_line":305,"line":205,"name":"fetch_klines","source":"def fetch_klines(symbol, limit=365, interval=\"1d\"):\n key = str(symbol).upper()\n pair = KLINE_SYMBOLS.get(key)\n if not pair:\n raise HTTPException(404, \"unknown symbol\")\n interval = str(interval or \"1d\")\n if interval not in {\"1d\", \"1M\"}:\n raise HTTPException(400, \"unsupported interval\")\n limit = max(1, min(int(limit), 1000))\n if interval == \"1d\":\n limit = max(30, limit)\n cache_key = (key, interval, limit)\n now = time.time()\n cached = KLINE_CACHE.get(cache_key)\n if cached and now - cached[0] < KLINE_CACHE_TTL:\n result = dict(cached[1])\n result[\"source\"] = \"cache\"\n result[\"stale\"] = False\n result[\"cache_age_seconds\"] = int(now - cached[0])\n return result\n stale = cached if cached and now - cached[0] <= KLINE_CACHE_MAX_STALE else None\n remaining = limit\n end_time = None\n collected = []\n try:\n while remaining > 0:\n page_limit = min(1000 if interval == \"1M\" else 100, remaining)\n params = {\"symbol\": pair, \"interval\": interval, \"limit\": page_limit}\n if end_time is not None:\n params[\"endTime\"] = end_time\n query = urllib.parse.urlencode(params)\n request = urllib.request.Request(\n \"https://data-api.binance.vision/api/v3/klines?\" + query,\n headers={\"User-Agent\": \"crypto-portfolio/1.0\"},\n )\n rows = None\n last_error = None\n for attempt in range(3):\n try:\n with urllib.request.urlopen(request, timeout=20) as response:\n rows = json.load(response)\n break\n except Exception as exc:\n last_error = exc\n if attempt < 2:\n time.sleep(0.5 * (attempt + 1))\n if rows is None:\n raise last_error\n if not isinstance(rows, list) or not rows:\n break\n collected.extend(rows)\n remaining -= len(rows)\n if len(rows) < page_limit:\n break\n end_time = int(rows[0][0]) - 1\n except Exception as exc:\n if stale:\n result = dict(stale[1])\n result[\"source\"] = \"stale-cache\"\n result[\"stale\"] = True\n result[\"cache_age_seconds\"] = int(now - stale[0])\n "},{"end_line":310,"line":308,"name":"klines","source":"def klines(symbol: str, request: Request, limit: int = 365, interval: str = \"1d\"):\n require_login(request)\n return fetch_klines(symbol, limit, interval)"},{"end_line":344,"line":313,"name":"chart_health","source":"def chart_health():\n now = time.time()\n details = {}\n bad = 0\n for symbol in (\"HBAR\", \"POL\", \"AVAX\", \"LINK\"):\n entry = KLINE_CACHE.get((symbol, \"1M\", 1000)) or KLINE_CACHE.get((symbol, \"1d\", 365))\n if not entry or not _valid_kline_result(entry[1]):\n details[symbol] = {\"status\": \"ERROR\", \"candles\": 0, \"cache_age_seconds\": None}\n bad += 1\n continue\n age = int(now - entry[0])\n expired = age > KLINE_CACHE_MAX_STALE\n details[symbol] = {\n \"status\": \"ERROR\" if expired else \"OK\",\n \"candles\": len(entry[1][\"candles\"]),\n \"cache_age_seconds\": age,\n \"stale\": age > KLINE_CACHE_TTL,\n \"source\": entry[1].get(\"source\", \"cache\"),\n }\n if expired:\n bad += 1\n library_ok = LIGHTWEIGHT_JS.is_file() and LIGHTWEIGHT_JS.stat().st_size > 100000\n if not library_ok:\n bad += 1\n payload = {\n \"status\": \"OK\" if bad == 0 else \"ERROR\",\n \"library_ok\": library_ok,\n \"disk_cache_present\": KLINE_CACHE_FILE.is_file(),\n \"symbols\": details,\n \"bad\": bad,\n }\n return JSONResponse(payload, status_code=200 if bad == 0 else 503)"},{"end_line":361,"line":347,"name":"state","source":"def state(request: Request):\n require_login(request)\n with db() as c:\n holdings = {r[\"coin_id\"]: {\"quantity\":r[\"quantity\"],\"avg_buy\":r[\"avg_buy\"]} for r in c.execute(\"SELECT * FROM holdings\")}\n alerts = [dict(r) for r in c.execute(\"SELECT * FROM alerts ORDER BY id DESC\")]\n history = {}\n z = {r[\"name\"] for r in c.execute(\"PRAGMA table_info(market_history)\")}\n cc = next((x for x in (\"coin_id\",\"coin\",\"asset_id\") if x in z),None); pc = next((x for x in (\"price\",\"current_price\",\"usd_price\",\"value\") if x in z),None); tc = next((x for x in (\"ts\",\"timestamp\",\"time\",\"created_at\",\"updated_at\") if x in z),None)\n if cc and pc and tc:\n q = f'SELECT \"{pc}\" p FROM market_history WHERE \"{cc}\"=? ORDER BY \"{tc}\"'\n for cid in COINS:\n v=[float(r[\"p\"]) for r in c.execute(q,(cid,)) if r[\"p\"] is not None]\n history[cid]=v if len(v)<=1200 else [v[int(i*(len(v)-1)/1199)] for i in range(1200)]\n # CRYPTO_HISTORY_STATE_V2\n return {\"coins\":COINS,\"market\":market_data(),\"holdings\":holdings,\"alerts\":alerts,\"history\":history}"},{"end_line":374,"line":364,"name":"save_holding","source":"async def save_holding(request: Request):\n require_login(request)\n x = await request.json()\n coin = str(x.get(\"coin_id\",\"\"))\n if coin not in COINS:\n raise HTTPException(400,\"bad coin\")\n quantity = max(0.0, float(x.get(\"quantity\",0)))\n avg_buy = max(0.0, float(x.get(\"avg_buy\",0)))\n with db() as c:\n c.execute(\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\",(coin,quantity,avg_buy)); c.commit()\n return {\"ok\":True}"},{"end_line":388,"line":377,"name":"add_alert","source":"async def add_alert(request: Request):\n require_login(request)\n x = await request.json()\n coin = str(x.get(\"coin_id\",\"\")); kind = str(x.get(\"kind\",\"\"))\n if coin not in COINS or kind not in {\"price_above\",\"price_below\",\"cap_above\",\"cap_below\",\"pump\",\"dump\"}:\n raise HTTPException(400,\"bad rule\")\n threshold = float(x.get(\"threshold\",0)); window = int(x.get(\"window_seconds\",0)); email = str(x.get(\"email\",\"\")).strip()\n if threshold <= 0 or \"@\" not in email or (kind in {\"pump\",\"dump\"} and window not in {300,900,1800,3600,14400,86400}):\n raise HTTPException(400,\"bad values\")\n with db() as c:\n cur = c.execute(\"INSERT INTO alerts(coin_id,kind,threshold,window_seconds,email) VALUES(?,?,?,?,?)\",(coin,kind,threshold,window,email)); c.commit()\n return {\"ok\":True,\"id\":cur.lastrowid}"},{"end_line":395,"line":391,"name":"delete_alert","source":"def delete_alert(alert_id: int, request: Request):\n require_login(request)\n with db() as c:\n c.execute(\"DELETE FROM alerts WHERE id=?\",(alert_id,)); c.commit()\n return {\"ok\":True}"},{"end_line":411,"line":410,"name":"_gram1_chart_javascript_v4","source":"def _gram1_chart_javascript_v4():\n return _Gram1Response(content=_gram1_base64.b64decode(_GRAM1_CHART_JS_B64), media_type=\"application/javascript\", headers={\"Cache-Control\":\"no-store\"})"},{"end_line":450,"line":413,"name":"_gram1_chart_data_v4","source":"async def _gram1_chart_data_v4(symbol: str, request: _Gram1Request):\n fn = globals().get(\"klines\")\n if fn is None:\n raise RuntimeError(\"klines route function not found\")\n kwargs = {}\n for name, parameter in _gram1_inspect.signature(fn).parameters.items():\n lname = name.lower()\n if lname in {\"symbol\", \"coin\", \"ticker\"}: kwargs[name] = symbol\n elif lname == \"request\": kwargs[name] = request\n elif lname == \"limit\": kwargs[name] = 1000\n elif lname == \"interval\": kwargs[name] = \"1M\"\n elif parameter.default is _gram1_inspect.Parameter.empty:\n raise RuntimeError(\"unsupported required klines parameter: \" + name)\n value = fn(**kwargs)\n if _gram1_inspect.isawaitable(value): value = await value\n if hasattr(value, \"body\"):\n value = _gram1_json.loads(bytes(value.body).decode(\"utf-8\"))\n if not isinstance(value, dict):\n raise RuntimeError(\"klines result is not an object\")\n if value.get(\"status\") not in (None, \"OK\"):\n raise RuntimeError(str(value.get(\"error\") or value.get(\"message\") or value.get(\"status\")))\n candles = value.get(\"candles\")\n if not isinstance(candles, list) and isinstance(value.get(\"result\"), dict): candles = value[\"result\"].get(\"candles\")\n if not isinstance(candles, list) or not candles:\n raise RuntimeError(\"klines returned no candles\")\n rows = []\n for item in candles:\n try:\n if isinstance(item, dict):\n t=item.get(\"time\",item.get(\"open_time\",item.get(\"openTime\")));o=float(item[\"open\"]);h=float(item[\"high\"]);l=float(item[\"low\"]);c=float(item[\"close\"])\n else:\n t=item[0];o=float(item[1]);h=float(item[2]);l=float(item[3]);c=float(item[4])\n if h < l: continue\n rows.append((str(t),o,h,l,c))\n except Exception:\n continue\n if not rows: raise RuntimeError(\"no valid OHLC rows\")\n return rows[-180:]"},{"end_line":464,"line":452,"name":"_gram1_chart_svg_v4","source":"def _gram1_chart_svg_v4(symbol: str, rows):\n width,height,left,right,top,bottom=1200,430,72,28,24,42\n lo=min(r[3] for r in rows);hi=max(r[2] for r in rows);span=max(hi-lo,abs(hi)*1e-9,1e-12)\n plot_w=width-left-right;plot_h=height-top-bottom;step=plot_w/max(len(rows),1);body=max(2.0,min(10.0,step*0.58))\n def y(v): return top+(hi-v)/span*plot_h\n parts=[f'', '']\n for n in range(5):\n yy=top+plot_h*n/4;price=hi-span*n/4;parts.append(f'');parts.append(f'{price:.8g}')\n for i,(_,o,h,l,c) in enumerate(rows):\n x=left+step*(i+.5);color=\"#63e6be\" if c>=o else \"#ff6b81\";yo=y(o);yc=y(c);yh=y(h);yl=y(l);rh=max(1.2,abs(yc-yo));ry=min(yo,yc)\n parts.append(f'');parts.append(f'')\n parts.append(f'{_gram1_html.escape(symbol)} / USDT · 1M · {len(rows)} свечей')\n return ''.join(parts)"},{"end_line":480,"line":467,"name":"_gram1_chart_svg_endpoint_v4","source":"async def _gram1_chart_svg_endpoint_v4(symbol: str, request: _Gram1Request):\n if not request.session.get(\"auth\"):\n return _Gram1Response(content=\"Требуется вход\", status_code=401, media_type=\"text/plain; charset=utf-8\")\n symbol=symbol.upper()\n if symbol not in _GRAM1_CHART_SYMBOLS:\n return _Gram1Response(content=\"Неизвестная монета\", status_code=404, media_type=\"text/plain; charset=utf-8\")\n try:\n rows=await _gram1_chart_data_v4(symbol,request)\n svg=_gram1_chart_svg_v4(symbol,rows)\n return _Gram1Response(content=svg,media_type=\"image/svg+xml\",headers={\"Cache-Control\":\"no-store\"})\n except Exception as exc:\n msg=_gram1_html.escape(str(exc))\n svg=f'Ошибка серверного графика {symbol}: {msg}'\n return _Gram1Response(content=svg,status_code=503,media_type=\"image/svg+xml\",headers={\"Cache-Control\":\"no-store\"})"}],"routes":[{"function":"login_page","line":53,"routes":[{"method":"GET","path":"/login"}]},{"function":"login","line":59,"routes":[{"method":"POST","path":"/login"}]},{"function":"logout","line":74,"routes":[{"method":"POST","path":"/logout"}]},{"function":"home","line":79,"routes":[{"method":"GET","path":"/"}]},{"function":"health","line":85,"routes":[{"method":"GET","path":"/health"}]},{"function":"coin_icon","line":93,"routes":[{"method":"GET","path":"/coin-icon/{coin_id}"}]},{"function":"lightweight_charts_js","line":142,"routes":[{"method":"GET","path":"/vendor/lightweight-charts.js"}]},{"function":"klines","line":308,"routes":[{"method":"GET","path":"/api/klines/{symbol}"}]},{"function":"chart_health","line":313,"routes":[{"method":"GET","path":"/api/chart-health"}]},{"function":"state","line":347,"routes":[{"method":"GET","path":"/api/state"}]},{"function":"save_holding","line":364,"routes":[{"method":"POST","path":"/api/holding"}]},{"function":"add_alert","line":377,"routes":[{"method":"POST","path":"/api/alert"}]},{"function":"delete_alert","line":391,"routes":[{"method":"DELETE","path":"/api/alert/{alert_id}"}]},{"function":"_gram1_chart_javascript_v4","line":410,"routes":[{"method":"GET","path":"/vendor/gram1-charts-v4.js"}]},{"function":"_gram1_chart_svg_endpoint_v4","line":467,"routes":[{"method":"GET","path":"/api/chart-svg/{symbol}"}]}]},"changes_made":false,"command_id":"GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z","compose":{"compose_file":"compose.yaml","config_rc":0,"ps_excerpt":"{\"Command\":\"\\\"uvicorn app:app --h…\\\"\",\"CreatedAt\":\"2026-08-01 16:21:30 +0300 MSK\",\"ExitCode\":0,\"Health\":\"\",\"ID\":\"dc202c12ba81\",\"Image\":\"crypto-portfolio-web\",\"Labels\":\"com.docker.compose.depends_on=,com.docker.compose.image=sha256:2a9339d475b2f4a01f060162f48a23b9590eb3ba4b036b46aad72adad540c40d,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=web,com.docker.compose.,com.docker.compose.oneoff=False,com.docker.compose.project=crypto-portfolio,com.docker.compose.version=2.26.1,com.docker.compose.container-number=1\",\"LocalVolumes\":\"0\",\"Mounts\":\"/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…\",\"Name\":\"crypto-portfolio\",\"Names\":\"crypto-portfolio\",\"Networks\":\"crypto-portfolio_default\",\"Ports\":\":8030-\\u003e8000/tcp\",\"Project\":\"crypto-portfolio\",\"Publishers\":[{\"URL\":\"\",\"TargetPort\":8000,\"PublishedPort\":8030,\"Protocol\":\"tcp\"}],\"RunningFor\":\"4 hours ago\",\"Service\":\"web\",\"Size\":\"0B\",\"State\":\"running\",\"Status\":\"Up 4 hours\"}\n{\"Command\":\"\\\"python /app/worker.…\\\"\",\"CreatedAt\":\"2026-08-01 13:56:57 +0300 MSK\",\"ExitCode\":0,\"Health\":\"\",\"ID\":\"9a2ac999b94a\",\"Image\":\"crypto-portfolio-worker\",\"Labels\":\"com.docker.compose.project=crypto-portfolio,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.version=2.26.1,com.docker.compose.image=sha256:7ff5fdff7ab373e6b22dfa1a677b000d83ff9d021e4464915078e506781fd0ed,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=worker,com.docker.compose.,com.docker.compose.container-number=1,com.docker.compose.depends_on=,com.docker.compose.oneoff=False\",\"LocalVolumes\":\"0\",\"Mounts\":\"/opt/stacks/cr…,/opt/stacks/cr…\",\"Name\":\"crypto-portfolio-worker\",\"Names\":\"crypto-portfolio-worker\",\"Networks\":\"crypto-portfolio_default\",\"Ports\":\"\",\"Project\":\"crypto-portfolio\",\"Publishers\":null,\"RunningFor\":\"6 hours ago\",\"Service\":\"worker\",\"Size\":\"0B\",\"State\":\"running\",\"Status\":\"Up 6 hours\"}","ps_rc":0,"services":["web","worker"],"services_rc":0,"stderr":""},"control_plane_error":null,"duration_seconds":0.043,"files":[{"bytes":32843,"exists":true,"gid":0,"mode":"0o640","path":"app.py","sha256":"3b55b78c015173eea66b67afb8fb9371f0d7d2e38a7679d4c433749f4c07301d","uid":0},{"bytes":10300,"exists":true,"gid":0,"mode":"0o640","path":"worker.py","sha256":"4a7b16ece826fe2d938f392db6b0b5ca028dcb6ff02fdff4ab431f114edf0e5c","uid":0},{"bytes":38036,"exists":true,"gid":0,"mode":"0o644","path":"templates/index.html","sha256":"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2","uid":0},{"bytes":1233,"exists":true,"gid":0,"mode":"0o644","path":"compose.yaml","sha256":"6d344e46b3de94ad86131a914df2ff3ff25e9d82f7ce0557cbffa468b8cdc1b8","uid":0}],"gitea_changed":false,"hostname":"core-apps","mutation_outcome":"NO_MUTATION","plan":{"current_usdc_present_anywhere":false,"market_sections_found":{"alerts":true,"charts":true,"sharp_moves":true,"top_coin_cards":true},"mutation_scope_candidate":["app.py","templates/index.html"],"next_step":"ONE_ATOMIC_REVERSIBLE_MUTATION_AFTER_REVIEW_OF_THIS_EXACT_REPORT","notes":["Do not add USDC to the shared market COINS/KLINE/alert lists.","Preserve HBAR/POL/AVAX/LINK market behavior unchanged.","Expose an editable USDC quantity and average purchase price only if the current positions UI already uses those inputs.","Portfolio totals must count USDC current value and cost basis; P/L for USDC follows the same row formula."],"positions_section_found":true,"quantity_or_cost_basis_storage_found":true,"requested_behavior":{"add_usdc_to_positions_only":true,"exclude_from_alerts":true,"exclude_from_charts":true,"exclude_from_sharp_moves":true,"exclude_from_top_coin_cards":true,"include_usdc_in_total_invested":true,"include_usdc_in_total_portfolio_value":true,"usdc_market_price_policy":"fixed_1_usd_unless_existing_source_is_explicitly_better"},"required_architecture":"separate portfolio-position asset set from market-tracked asset set; USDC must exist only in the former","worker_change_expected":false},"production_site_changed":false,"rc":0,"relevant_file_hits":[{"line":9,"path":"app.py","text":"DB = DATA / \"portfolio.db\""},{"line":20,"path":"app.py","text":"c.execute(\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\")"},{"line":89,"path":"app.py","text":"return {\"status\":\"OK\",\"type\":\"crypto-portfolio\",\"market_age_seconds\":age}"},{"line":111,"path":"app.py","text":"with httpx.Client(timeout=6, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as client:"},{"line":238,"path":"app.py","text":"headers={\"User-Agent\": \"crypto-portfolio/1.0\"},"},{"line":350,"path":"app.py","text":"holdings = {r[\"coin_id\"]: {\"quantity\":r[\"quantity\"],\"avg_buy\":r[\"avg_buy\"]} for r in c.execute(\"SELECT * FROM holdings\")}"},{"line":361,"path":"app.py","text":"return {\"coins\":COINS,\"market\":market_data(),\"holdings\":holdings,\"alerts\":alerts,\"history\":history}"},{"line":363,"path":"app.py","text":"@app.post(\"/api/holding\")"},{"line":364,"path":"app.py","text":"async def save_holding(request: Request):"},{"line":370,"path":"app.py","text":"quantity = max(0.0, float(x.get(\"quantity\",0)))"},{"line":373,"path":"app.py","text":"c.execute(\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\",(coin,quantity,avg_buy)); c.commit()"},{"line":4,"path":"compose.yaml","text":"container_name: crypto-portfolio"},{"line":29,"path":"compose.yaml","text":"container_name: crypto-portfolio-worker"},{"line":7,"path":"data/lightweight-charts.standalone.production.js","text":"!function(){\"use strict\";const t={title:\"\",visible:!0,hitTestTolerance:3,lastValueVisible:!0,priceLineVisible:!0,priceLineSource:0,priceLineWidth:1,priceLineColor:\"\",priceLineStyle:2,baseLineVisible:!0,baseLineWidth:1,baseLineColor:\"#B2B5BE\",baseLineStyle:0,priceFormat:{type:\"price\",precision:2,minMove:.01}};var i,n;function s(t,i){const n=function(t,i){switch(t){case 0:default:return[];case 1:return[i,i];case 2:return[2*i,2*i];case 3:return[6*i,6*i];case 4:return[i,4*i]}}(i,t.lineWidth);return t.setLineDash(n),n}function e(t,i,n,s){t.beginPath();const e=t.lineWidth%2?.5:0;t.moveTo(n,i+e),t.lineTo(s,i+e),t.stroke()}function r(t,i){if(!t)throw new Error(\"Assertion failed\"+(i?\": \"+i:\"\"))}function h(t){if(void 0===t)throw new Error(\"Value is undefined\");return t}function a(t){if(null===t)throw new Error(\"Value is null\");return t}function l(t){return a(h(t))}!function(t){t[t.Simple=0]=\"Simple\",t[t.WithSteps=1]=\"WithSteps\",t[t.Curved=2]=\"Curved\"}(i||(i={})),function(t){t[t.Solid=0]=\"Solid\","},{"line":3,"path":"templates/index.html","text":"
Стоимость портфеля
Вложено
Прибыль / убыток0%
Обновление рынкакаждые 5 минут
"},{"line":9,"path":"templates/index.html","text":""},{"line":109,"text":""},{"line":217,"text":""},{"line":231,"text":""},{"line":232,"text":""}],"sha256":"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2"},"worker":{"assignments":[{"end_line":8,"line":8,"literal":null,"names":["MARKET"],"source":"MARKET = DATA / \"market.json\""},{"end_line":11,"line":11,"literal":{"avalanche-2":"AVAX","chainlink":"LINK","hedera-hashgraph":"HBAR","polygon-ecosystem-token":"POL"},"names":["COINS"],"source":"COINS = {\"hedera-hashgraph\": \"HBAR\", \"polygon-ecosystem-token\": \"POL\", \"avalanche-2\": \"AVAX\", \"chainlink\": \"LINK\"}"},{"end_line":111,"line":111,"literal":null,"names":["quantity"],"source":"quantity = float(r[\"quantity\"])"},{"end_line":112,"line":112,"literal":null,"names":["avg"],"source":"avg = float(r[\"avg_buy\"])"},{"end_line":114,"line":114,"literal":null,"names":["invested"],"source":"invested = quantity * avg"},{"end_line":123,"line":123,"literal":null,"names":["coin"],"source":"coin = market[\"coins\"].get(a[\"coin_id\"])"},{"end_line":128,"line":128,"literal":null,"names":["price"],"source":"price = float(coin[\"price\"])"},{"end_line":179,"line":179,"literal":null,"names":["market"],"source":"market = fetch_market()"}],"relevant_functions":[{"end_line":23,"line":14,"name":"connect","source":"def connect():\n c = sqlite3.connect(DB, timeout=15)\n c.row_factory = sqlite3.Row\n c.execute(\"PRAGMA journal_mode=WAL\")\n c.execute(\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\")\n c.execute(\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\")\n c.execute(\"CREATE TABLE IF NOT EXISTS market_history(ts INTEGER NOT NULL, coin_id TEXT NOT NULL, price REAL NOT NULL, market_cap REAL NOT NULL, volume REAL NOT NULL DEFAULT 0, PRIMARY KEY(ts,coin_id))\")\n c.execute(\"CREATE INDEX IF NOT EXISTS idx_market_history_coin_ts ON market_history(coin_id,ts)\")\n c.commit()\n return c"},{"end_line":75,"line":41,"name":"fetch_market","source":"def fetch_market():\n params = {\"vs_currency\":\"usd\",\"ids\":\",\".join(COINS),\"order\":\"market_cap_desc\",\"per_page\":50,\"page\":1,\"sparkline\":\"true\",\"price_change_percentage\":\"1h,24h,7d\"}\n with httpx.Client(timeout=25, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as client:\n r = client.get(\"https://api.coingecko.com/api/v3/coins/markets\", params=params)\n r.raise_for_status()\n rows = r.json()\n now = int(time.time())\n usd_rub = 0.0\n try:\n with open(MARKET, \"r\", encoding=\"utf-8\") as previous_file:\n previous_rate = float((json.load(previous_file) or {}).get(\"usd_rub\") or 0)\n if 40 <= previous_rate <= 200:\n usd_rub = previous_rate\n except Exception:\n pass\n try:\n with httpx.Client(timeout=15, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as fx_client:\n fx_data = fx_client.get(\"https://api.coingecko.com/api/v3/simple/price\", params={\"ids\":\"bitcoin\",\"vs_currencies\":\"usd,rub\"}).json()[\"bitcoin\"]\n candidate = float(fx_data[\"rub\"]) / float(fx_data[\"usd\"])\n if 40 <= candidate <= 200:\n usd_rub = candidate\n except Exception:\n pass\n if not 40 <= usd_rub <= 200:\n usd_rub = 77.025093\n result = {\"updated_at\":now,\"currency\":\"usd\",\"usd_rub\":usd_rub,\"coins\":{}}\n\n for x in rows:\n cid = str(x.get(\"id\",\"\"))\n if cid not in COINS:\n continue\n result[\"coins\"][cid] = {\"symbol\":COINS[cid],\"name\":x.get(\"name\"),\"price\":float(x.get(\"current_price\") or 0),\"market_cap\":float(x.get(\"market_cap\") or 0),\"rank\":x.get(\"market_cap_rank\"),\"volume\":float(x.get(\"total_volume\") or 0),\"high_24h\":x.get(\"high_24h\"),\"low_24h\":x.get(\"low_24h\"),\"change_1h\":x.get(\"price_change_percentage_1h_in_currency\"),\"change_24h\":x.get(\"price_change_percentage_24h_in_currency\"),\"change_7d\":x.get(\"price_change_percentage_7d_in_currency\"),\"sparkline_7d\":((x.get(\"sparkline_in_7d\") or {}).get(\"price\") or []),\"last_updated\":x.get(\"last_updated\")}\n if len(result[\"coins\"]) != len(COINS):\n raise RuntimeError(\"incomplete market response\")\n return result"},{"end_line":79,"line":77,"name":"reference_price","source":"def reference_price(c, coin_id, target_ts):\n r = c.execute(\"SELECT price FROM market_history WHERE coin_id=? AND ts<=? ORDER BY ts DESC LIMIT 1\",(coin_id,target_ts)).fetchone()\n return float(r[\"price\"]) if r else None"},{"end_line":117,"line":107,"name":"portfolio_text","source":"def portfolio_text(c, coin_id, price):\n r = c.execute(\"SELECT quantity,avg_buy FROM holdings WHERE coin_id=?\",(coin_id,)).fetchone()\n if not r:\n return \"\"\n quantity = float(r[\"quantity\"])\n avg = float(r[\"avg_buy\"])\n value = quantity * price\n invested = quantity * avg\n pnl = value - invested\n pct = pnl / invested * 100 if invested else 0\n return \"\\nКоличество монет: {:.8f}\\nТекущая стоимость позиции: ${:,.2f}\\nПрибыль или убыток: ${:,.2f} ({:+.2f}%)\\n\".format(quantity,value,pnl,pct)"},{"end_line":175,"line":119,"name":"evaluate","source":"def evaluate(c, market):\n now = int(time.time())\n triggered = 0\n for a in c.execute(\"SELECT * FROM alerts WHERE enabled=1 ORDER BY id\").fetchall():\n coin = market[\"coins\"].get(a[\"coin_id\"])\n if not coin:\n continue\n kind = a[\"kind\"]\n threshold = float(a[\"threshold\"])\n price = float(coin[\"price\"])\n cap = float(coin[\"market_cap\"])\n latched = int(a[\"latched\"])\n fired = False\n reset = False\n description = \"\"\n if kind == \"price_above\":\n fired = price >= threshold\n reset = price < threshold * 0.99\n description = \"Цена {} достигла ${:,.8f}, порог выше или равно ${:,.8f}.\".format(coin[\"symbol\"],price,threshold)\n elif kind == \"price_below\":\n fired = price <= threshold\n reset = price > threshold * 1.01\n description = \"Цена {} снизилась до ${:,.8f}, порог ниже или равно ${:,.8f}.\".format(coin[\"symbol\"],price,threshold)\n elif kind == \"cap_above\":\n fired = cap >= threshold\n reset = cap < threshold * 0.99\n description = \"Капитализация {} достигла ${:,.0f}, порог выше или равно ${:,.0f}.\".format(coin[\"symbol\"],cap,threshold)\n elif kind == \"cap_below\":\n fired = cap <= threshold\n reset = cap > threshold * 1.01\n description = \"Капитализация {} снизилась до ${:,.0f}, порог ниже или равно ${:,.0f}.\".format(coin[\"symbol\"],cap,threshold)\n elif kind in {\"pump\",\"dump\"}:\n old = reference_price(c,a[\"coin_id\"],now-int(a[\"window_seconds\"]))\n if old and old > 0:\n change = (price / old - 1) * 100\n if kind == \"pump\":\n fired = change >= threshold\n reset = change < threshold * 0.5\n description = \"Резкий памп {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\".format(coin[\"symbol\"],change,int(a[\"window_seconds\"])//60,old,price)\n else:\n fired = change <= -threshold\n reset = change > -threshold * 0.5\n description = \"Резкое падение {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\".format(coin[\"symbol\"],change,int(a[\"window_seconds\"])//60,old,price)\n if reset and latched:\n c.execute(\"UPDATE alerts SET latched=0 WHERE id=?\","},{"end_line":190,"line":177,"name":"cycle","source":"def cycle():\n DATA.mkdir(parents=True, exist_ok=True)\n market = fetch_market()\n now = int(market[\"updated_at\"])\n with connect() as c:\n for cid, x in market[\"coins\"].items():\n c.execute(\"INSERT OR REPLACE INTO market_history(ts,coin_id,price,market_cap,volume) VALUES(?,?,?,?,?)\",(now,cid,x[\"price\"],x[\"market_cap\"],x[\"volume\"]))\n c.execute(\"DELETE FROM market_history WHERE ts\",\"names\":[\"_GRAM1_CHART_JS_B64\"],\"source\":\"_GRAM1_CHART_JS_B64 = \\\"\\\"\"},{\"end_line\":407,\"line\":407,\"literal\":[\"AVAX\",\"POL\",\"HBAR\",\"LINK\"],\"names\":[\"_GRAM1_CHART_SYMBOLS\"],\"source\":\"_GRAM1_CHART_SYMBOLS = {\\\"HBAR\\\", \\\"POL\\\", \\\"AVAX\\\", \\\"LINK\\\"}\"},{\"end_line\":459,\"line\":459,\"literal\":null,\"names\":[\"price\"],\"source\":\"price=hi-span*n/4\"},{\"end_line\":470,\"line\":470,\"literal\":null,\"names\":[\"symbol\"],\"source\":\"symbol=symbol.upper()\"}],\"relevant_functions\":[{\"end_line\":23,\"line\":16,\"name\":\"db\",\"source\":\"def db():\\n c = sqlite3.connect(DB, timeout=10)\\n c.row_factory = sqlite3.Row\\n c.execute(\\\"PRAGMA journal_mode=WAL\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\\\")\\n c.commit()\\n return c\"},{\"end_line\":47,\"line\":42,\"name\":\"market_data\",\"source\":\"def market_data():\\n try:\\n obj = json.loads(MARKET.read_text())\\n return obj if isinstance(obj, dict) else {}\\n except Exception:\\n return {}\"},{\"end_line\":56,\"line\":53,\"name\":\"login_page\",\"source\":\"def login_page(request: Request):\\n if request.session.get(\\\"auth\\\"):\\n return RedirectResponse(\\\"/\\\", 303)\\n return HTMLResponse(\\\"\\\"\\\"Gram Crypto

Gram Crypto

https://gram1.ru/

Введите пароль для доступа к криптопортфелю.

\\\"\\\"\\\" + (\\\"Неверный пароль\\\" if request.query_params.get(\\\"bad\\\") else \\\"\\\") + \\\"\\\"\\\"
\\\"\\\"\\\")\"},{\"end_line\":89,\"line\":85,\"name\":\"health\",\"source\":\"def health():\\n age = None\\n if MARKET.exists():\\n age = int(time.time() - MARKET.stat().st_mtime)\\n return {\\\"status\\\":\\\"OK\\\",\\\"type\\\":\\\"crypto-portfolio\\\",\\\"market_age_seconds\\\":age}\"},{\"end_line\":134,\"line\":93,\"name\":\"coin_icon\",\"source\":\"def coin_icon(coin_id: str):\\n import html\\n import os\\n import re\\n from pathlib import Path\\n import httpx\\n from fastapi import HTTPException\\n from fastapi.responses import Response\\n if not re.fullmatch(r\\\"[a-z0-9-]{1,64}\\\", coin_id):\\n raise HTTPException(status_code=404)\\n cache_dir = Path(\\\"/data/coin-icons\\\")\\n cache_dir.mkdir(parents=True, exist_ok=True)\\n body_path = cache_dir / (coin_id + \\\".img\\\")\\n type_path = cache_dir / (coin_id + \\\".type\\\")\\n if body_path.exists() and body_path.stat().st_size > 128:\\n media_type = type_path.read_text().strip() if type_path.exists() else \\\"image/png\\\"\\n return Response(body_path.read_bytes(), media_type=media_type, headers={\\\"Cache-Control\\\":\\\"public, max-age=86400\\\"})\\n try:\\n with httpx.Client(timeout=6, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as client:\\n response = client.get(\\\"https://api.coingecko.com/api/v3/coins/markets\\\", params={\\\"vs_currency\\\":\\\"usd\\\",\\\"ids\\\":coin_id,\\\"sparkline\\\":\\\"false\\\"})\\n response.raise_for_status()\\n rows = response.json()\\n if not rows or not rows[0].get(\\\"image\\\"):\\n raise ValueError(\\\"image URL missing\\\")\\n image = client.get(rows[0][\\\"image\\\"])\\n image.raise_for_status()\\n media_type = (image.headers.get(\\\"content-type\\\") or \\\"image/png\\\").split(\\\";\\\", 1)[0]\\n if not media_type.startswith(\\\"image/\\\") or len(image.content) <= 128:\\n raise ValueError(\\\"invalid image\\\")\\n tmp_path = body_path.with_suffix(\\\".tmp\\\")\\n tmp_path.write_bytes(image.content)\\n os.replace(tmp_path, body_path)\\n type_path.write_text(media_type)\\n return Response(image.content, media_type=media_type, headers={\\\"Cache-Control\\\":\\\"public, max-age=86400\\\"})\\n except Exception:\\n symbols = {\\\"bitcoin\\\":\\\"₿\\\",\\\"ethereum\\\":\\\"Ξ\\\",\\\"solana\\\":\\\"S\\\",\\\"binancecoin\\\":\\\"BNB\\\",\\\"ripple\\\":\\\"XRP\\\",\\\"cardano\\\":\\\"ADA\\\",\\\"hedera-hashgraph\\\":\\\"HBAR\\\",\\\"hedera\\\":\\\"HBAR\\\",\\\"avalanche-2\\\":\\\"AVAX\\\",\\\"chainlink\\\":\\\"LINK\\\",\\\"toncoin\\\":\\\"TON\\\",\\\"dogecoin\\\":\\\"Ð\\\"}\\n label = symbols.get(coin_id, coin_id[:3].upper())\\n palette = [\\\"#f7931a\\\",\\\"#627eea\\\",\\\"#14f195\\\",\\\"#f3ba2f\\\",\\\"#23292f\\\",\\\"#3468d4\\\",\\\"#6f5af5\\\"]\\n color = palette[sum(ord(ch) for ch in coin_id) % len(palette)]\\n safe_label = html.escape(label)\\n svg = f'= 2\\n )\"},{\"end_line\":183,\"line\":161,\"name\":\"_load_kline_cache\",\"source\":\"def _load_kline_cache():\\n if not KLINE_CACHE_FILE.is_file():\\n return\\n try:\\n payload = json.loads(KLINE_CACHE_FILE.read_text())\\n entries = payload.get(\\\"entries\\\", {})\\n now = time.time()\\n for name, item in entries.items():\\n parts = name.split(\\\":\\\")\\n if len(parts) == 3:\\n symbol, interval, limit_text = parts\\n elif len(parts) == 2:\\n symbol, limit_text = parts\\n interval = \\\"1d\\\"\\n else:\\n continue\\n limit = int(limit_text)\\n saved_at = float(item.get(\\\"saved_at\\\", 0))\\n result = item.get(\\\"result\\\")\\n if now - saved_at <= KLINE_CACHE_MAX_STALE and _valid_kline_result(result):\\n KLINE_CACHE[(symbol, interval, limit)] = (saved_at, result)\\n except Exception:\\n return\"},{\"end_line\":201,\"line\":185,\"name\":\"_save_kline_cache\",\"source\":\"def _save_kline_cache():\\n with KLINE_CACHE_WRITE_LOCK:\\n entries = {}\\n for key, value in list(KLINE_CACHE.items()):\\n if len(key) == 3:\\n symbol, interval, limit = key\\n else:\\n symbol, limit = key\\n interval = \\\"1d\\\"\\n saved_at, result = value\\n if _valid_kline_result(result):\\n entries[symbol + \\\":\\\" + interval + \\\":\\\" + str(limit)] = {\\\"saved_at\\\": saved_at, \\\"result\\\": result}\\n payload = {\\\"version\\\": 1, \\\"saved_at\\\": int(time.time()), \\\"entries\\\": entries}\\n temporary = KLINE_CACHE_FILE.with_suffix(\\\".json.tmp\\\")\\n temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(\\\",\\\", \\\":\\\")))\\n os.replace(temporary, KLINE_CACHE_FILE)\\n os.chmod(KLINE_CACHE_FILE, 0o644)\"},{\"end_line\":305,\"line\":205,\"name\":\"fetch_klines\",\"source\":\"def fetch_klines(symbol, limit=365, interval=\\\"1d\\\"):\\n key = str(symbol).upper()\\n pair = KLINE_SYMBOLS.get(key)\\n if not pair:\\n raise HTTPException(404, \\\"unknown symbol\\\")\\n interval = str(interval or \\\"1d\\\")\\n if interval not in {\\\"1d\\\", \\\"1M\\\"}:\\n raise HTTPException(400, \\\"unsupported interval\\\")\\n limit = max(1, min(int(limit), 1000))\\n if interval == \\\"1d\\\":\\n limit = max(30, limit)\\n cache_key = (key, interval, limit)\\n now = time.time()\\n cached = KLINE_CACHE.get(cache_key)\\n if cached and now - cached[0] < KLINE_CACHE_TTL:\\n result = dict(cached[1])\\n result[\\\"source\\\"] = \\\"cache\\\"\\n result[\\\"stale\\\"] = False\\n result[\\\"cache_age_seconds\\\"] = int(now - cached[0])\\n return result\\n stale = cached if cached and now - cached[0] <= KLINE_CACHE_MAX_STALE else None\\n remaining = limit\\n end_time = None\\n collected = []\\n try:\\n while remaining > 0:\\n page_limit = min(1000 if interval == \\\"1M\\\" else 100, remaining)\\n params = {\\\"symbol\\\": pair, \\\"interval\\\": interval, \\\"limit\\\": page_limit}\\n if end_time is not None:\\n params[\\\"endTime\\\"] = end_time\\n query = urllib.parse.urlencode(params)\\n request = urllib.request.Request(\\n \\\"https://data-api.binance.vision/api/v3/klines?\\\" + query,\\n headers={\\\"User-Agent\\\": \\\"crypto-portfolio/1.0\\\"},\\n )\\n rows = None\\n last_error = None\\n for attempt in range(3):\\n try:\\n with urllib.request.urlopen(request, timeout=20) as response:\\n rows = json.load(response)\\n break\\n except Exception as exc:\\n last_error = exc\\n if attempt < 2:\\n time.sleep(0.5 * (attempt + 1))\\n if rows is None:\\n raise last_error\\n if not isinstance(rows, list) or not rows:\\n break\\n collected.extend(rows)\\n remaining -= len(rows)\\n if len(rows) < page_limit:\\n break\\n end_time = int(rows[0][0]) - 1\\n except Exception as exc:\\n if stale:\\n result = dict(stale[1])\\n result[\\\"source\\\"] = \\\"stale-cache\\\"\\n result[\\\"stale\\\"] = True\\n result[\\\"cache_age_seconds\\\"] = int(now - stale[0])\\n \"},{\"end_line\":310,\"line\":308,\"name\":\"klines\",\"source\":\"def klines(symbol: str, request: Request, limit: int = 365, interval: str = \\\"1d\\\"):\\n require_login(request)\\n return fetch_klines(symbol, limit, interval)\"},{\"end_line\":344,\"line\":313,\"name\":\"chart_health\",\"source\":\"def chart_health():\\n now = time.time()\\n details = {}\\n bad = 0\\n for symbol in (\\\"HBAR\\\", \\\"POL\\\", \\\"AVAX\\\", \\\"LINK\\\"):\\n entry = KLINE_CACHE.get((symbol, \\\"1M\\\", 1000)) or KLINE_CACHE.get((symbol, \\\"1d\\\", 365))\\n if not entry or not _valid_kline_result(entry[1]):\\n details[symbol] = {\\\"status\\\": \\\"ERROR\\\", \\\"candles\\\": 0, \\\"cache_age_seconds\\\": None}\\n bad += 1\\n continue\\n age = int(now - entry[0])\\n expired = age > KLINE_CACHE_MAX_STALE\\n details[symbol] = {\\n \\\"status\\\": \\\"ERROR\\\" if expired else \\\"OK\\\",\\n \\\"candles\\\": len(entry[1][\\\"candles\\\"]),\\n \\\"cache_age_seconds\\\": age,\\n \\\"stale\\\": age > KLINE_CACHE_TTL,\\n \\\"source\\\": entry[1].get(\\\"source\\\", \\\"cache\\\"),\\n }\\n if expired:\\n bad += 1\\n library_ok = LIGHTWEIGHT_JS.is_file() and LIGHTWEIGHT_JS.stat().st_size > 100000\\n if not library_ok:\\n bad += 1\\n payload = {\\n \\\"status\\\": \\\"OK\\\" if bad == 0 else \\\"ERROR\\\",\\n \\\"library_ok\\\": library_ok,\\n \\\"disk_cache_present\\\": KLINE_CACHE_FILE.is_file(),\\n \\\"symbols\\\": details,\\n \\\"bad\\\": bad,\\n }\\n return JSONResponse(payload, status_code=200 if bad == 0 else 503)\"},{\"end_line\":361,\"line\":347,\"name\":\"state\",\"source\":\"def state(request: Request):\\n require_login(request)\\n with db() as c:\\n holdings = {r[\\\"coin_id\\\"]: {\\\"quantity\\\":r[\\\"quantity\\\"],\\\"avg_buy\\\":r[\\\"avg_buy\\\"]} for r in c.execute(\\\"SELECT * FROM holdings\\\")}\\n alerts = [dict(r) for r in c.execute(\\\"SELECT * FROM alerts ORDER BY id DESC\\\")]\\n history = {}\\n z = {r[\\\"name\\\"] for r in c.execute(\\\"PRAGMA table_info(market_history)\\\")}\\n cc = next((x for x in (\\\"coin_id\\\",\\\"coin\\\",\\\"asset_id\\\") if x in z),None); pc = next((x for x in (\\\"price\\\",\\\"current_price\\\",\\\"usd_price\\\",\\\"value\\\") if x in z),None); tc = next((x for x in (\\\"ts\\\",\\\"timestamp\\\",\\\"time\\\",\\\"created_at\\\",\\\"updated_at\\\") if x in z),None)\\n if cc and pc and tc:\\n q = f'SELECT \\\"{pc}\\\" p FROM market_history WHERE \\\"{cc}\\\"=? ORDER BY \\\"{tc}\\\"'\\n for cid in COINS:\\n v=[float(r[\\\"p\\\"]) for r in c.execute(q,(cid,)) if r[\\\"p\\\"] is not None]\\n history[cid]=v if len(v)<=1200 else [v[int(i*(len(v)-1)/1199)] for i in range(1200)]\\n # CRYPTO_HISTORY_STATE_V2\\n return {\\\"coins\\\":COINS,\\\"market\\\":market_data(),\\\"holdings\\\":holdings,\\\"alerts\\\":alerts,\\\"history\\\":history}\"},{\"end_line\":374,\"line\":364,\"name\":\"save_holding\",\"source\":\"async def save_holding(request: Request):\\n require_login(request)\\n x = await request.json()\\n coin = str(x.get(\\\"coin_id\\\",\\\"\\\"))\\n if coin not in COINS:\\n raise HTTPException(400,\\\"bad coin\\\")\\n quantity = max(0.0, float(x.get(\\\"quantity\\\",0)))\\n avg_buy = max(0.0, float(x.get(\\\"avg_buy\\\",0)))\\n with db() as c:\\n c.execute(\\\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\\\",(coin,quantity,avg_buy)); c.commit()\\n return {\\\"ok\\\":True}\"},{\"end_line\":388,\"line\":377,\"name\":\"add_alert\",\"source\":\"async def add_alert(request: Request):\\n require_login(request)\\n x = await request.json()\\n coin = str(x.get(\\\"coin_id\\\",\\\"\\\")); kind = str(x.get(\\\"kind\\\",\\\"\\\"))\\n if coin not in COINS or kind not in {\\\"price_above\\\",\\\"price_below\\\",\\\"cap_above\\\",\\\"cap_below\\\",\\\"pump\\\",\\\"dump\\\"}:\\n raise HTTPException(400,\\\"bad rule\\\")\\n threshold = float(x.get(\\\"threshold\\\",0)); window = int(x.get(\\\"window_seconds\\\",0)); email = str(x.get(\\\"email\\\",\\\"\\\")).strip()\\n if threshold <= 0 or \\\"@\\\" not in email or (kind in {\\\"pump\\\",\\\"dump\\\"} and window not in {300,900,1800,3600,14400,86400}):\\n raise HTTPException(400,\\\"bad values\\\")\\n with db() as c:\\n cur = c.execute(\\\"INSERT INTO alerts(coin_id,kind,threshold,window_seconds,email) VALUES(?,?,?,?,?)\\\",(coin,kind,threshold,window,email)); c.commit()\\n return {\\\"ok\\\":True,\\\"id\\\":cur.lastrowid}\"},{\"end_line\":395,\"line\":391,\"name\":\"delete_alert\",\"source\":\"def delete_alert(alert_id: int, request: Request):\\n require_login(request)\\n with db() as c:\\n c.execute(\\\"DELETE FROM alerts WHERE id=?\\\",(alert_id,)); c.commit()\\n return {\\\"ok\\\":True}\"},{\"end_line\":411,\"line\":410,\"name\":\"_gram1_chart_javascript_v4\",\"source\":\"def _gram1_chart_javascript_v4():\\n return _Gram1Response(content=_gram1_base64.b64decode(_GRAM1_CHART_JS_B64), media_type=\\\"application/javascript\\\", headers={\\\"Cache-Control\\\":\\\"no-store\\\"})\"},{\"end_line\":450,\"line\":413,\"name\":\"_gram1_chart_data_v4\",\"source\":\"async def _gram1_chart_data_v4(symbol: str, request: _Gram1Request):\\n fn = globals().get(\\\"klines\\\")\\n if fn is None:\\n raise RuntimeError(\\\"klines route function not found\\\")\\n kwargs = {}\\n for name, parameter in _gram1_inspect.signature(fn).parameters.items():\\n lname = name.lower()\\n if lname in {\\\"symbol\\\", \\\"coin\\\", \\\"ticker\\\"}: kwargs[name] = symbol\\n elif lname == \\\"request\\\": kwargs[name] = request\\n elif lname == \\\"limit\\\": kwargs[name] = 1000\\n elif lname == \\\"interval\\\": kwargs[name] = \\\"1M\\\"\\n elif parameter.default is _gram1_inspect.Parameter.empty:\\n raise RuntimeError(\\\"unsupported required klines parameter: \\\" + name)\\n value = fn(**kwargs)\\n if _gram1_inspect.isawaitable(value): value = await value\\n if hasattr(value, \\\"body\\\"):\\n value = _gram1_json.loads(bytes(value.body).decode(\\\"utf-8\\\"))\\n if not isinstance(value, dict):\\n raise RuntimeError(\\\"klines result is not an object\\\")\\n if value.get(\\\"status\\\") not in (None, \\\"OK\\\"):\\n raise RuntimeError(str(value.get(\\\"error\\\") or value.get(\\\"message\\\") or value.get(\\\"status\\\")))\\n candles = value.get(\\\"candles\\\")\\n if not isinstance(candles, list) and isinstance(value.get(\\\"result\\\"), dict): candles = value[\\\"result\\\"].get(\\\"candles\\\")\\n if not isinstance(candles, list) or not candles:\\n raise RuntimeError(\\\"klines returned no candles\\\")\\n rows = []\\n for item in candles:\\n try:\\n if isinstance(item, dict):\\n t=item.get(\\\"time\\\",item.get(\\\"open_time\\\",item.get(\\\"openTime\\\")));o=float(item[\\\"open\\\"]);h=float(item[\\\"high\\\"]);l=float(item[\\\"low\\\"]);c=float(item[\\\"close\\\"])\\n else:\\n t=item[0];o=float(item[1]);h=float(item[2]);l=float(item[3]);c=float(item[4])\\n if h < l: continue\\n rows.append((str(t),o,h,l,c))\\n except Exception:\\n continue\\n if not rows: raise RuntimeError(\\\"no valid OHLC rows\\\")\\n return rows[-180:]\"},{\"end_line\":464,\"line\":452,\"name\":\"_gram1_chart_svg_v4\",\"source\":\"def _gram1_chart_svg_v4(symbol: str, rows):\\n width,height,left,right,top,bottom=1200,430,72,28,24,42\\n lo=min(r[3] for r in rows);hi=max(r[2] for r in rows);span=max(hi-lo,abs(hi)*1e-9,1e-12)\\n plot_w=width-left-right;plot_h=height-top-bottom;step=plot_w/max(len(rows),1);body=max(2.0,min(10.0,step*0.58))\\n def y(v): return top+(hi-v)/span*plot_h\\n parts=[f'', '']\\n for n in range(5):\\n yy=top+plot_h*n/4;price=hi-span*n/4;parts.append(f'');parts.append(f'{price:.8g}')\\n for i,(_,o,h,l,c) in enumerate(rows):\\n x=left+step*(i+.5);color=\\\"#63e6be\\\" if c>=o else \\\"#ff6b81\\\";yo=y(o);yc=y(c);yh=y(h);yl=y(l);rh=max(1.2,abs(yc-yo));ry=min(yo,yc)\\n parts.append(f'');parts.append(f'')\\n parts.append(f'{_gram1_html.escape(symbol)} / USDT · 1M · {len(rows)} свечей')\\n return ''.join(parts)\"},{\"end_line\":480,\"line\":467,\"name\":\"_gram1_chart_svg_endpoint_v4\",\"source\":\"async def _gram1_chart_svg_endpoint_v4(symbol: str, request: _Gram1Request):\\n if not request.session.get(\\\"auth\\\"):\\n return _Gram1Response(content=\\\"Требуется вход\\\", status_code=401, media_type=\\\"text/plain; charset=utf-8\\\")\\n symbol=symbol.upper()\\n if symbol not in _GRAM1_CHART_SYMBOLS:\\n return _Gram1Response(content=\\\"Неизвестная монета\\\", status_code=404, media_type=\\\"text/plain; charset=utf-8\\\")\\n try:\\n rows=await _gram1_chart_data_v4(symbol,request)\\n svg=_gram1_chart_svg_v4(symbol,rows)\\n return _Gram1Response(content=svg,media_type=\\\"image/svg+xml\\\",headers={\\\"Cache-Control\\\":\\\"no-store\\\"})\\n except Exception as exc:\\n msg=_gram1_html.escape(str(exc))\\n svg=f'Ошибка серверного графика {symbol}: {msg}'\\n return _Gram1Response(content=svg,status_code=503,media_type=\\\"image/svg+xml\\\",headers={\\\"Cache-Control\\\":\\\"no-store\\\"})\"}],\"routes\":[{\"function\":\"login_page\",\"line\":53,\"routes\":[{\"method\":\"GET\",\"path\":\"/login\"}]},{\"function\":\"login\",\"line\":59,\"routes\":[{\"method\":\"POST\",\"path\":\"/login\"}]},{\"function\":\"logout\",\"line\":74,\"routes\":[{\"method\":\"POST\",\"path\":\"/logout\"}]},{\"function\":\"home\",\"line\":79,\"routes\":[{\"method\":\"GET\",\"path\":\"/\"}]},{\"function\":\"health\",\"line\":85,\"routes\":[{\"method\":\"GET\",\"path\":\"/health\"}]},{\"function\":\"coin_icon\",\"line\":93,\"routes\":[{\"method\":\"GET\",\"path\":\"/coin-icon/{coin_id}\"}]},{\"function\":\"lightweight_charts_js\",\"line\":142,\"routes\":[{\"method\":\"GET\",\"path\":\"/vendor/lightweight-charts.js\"}]},{\"function\":\"klines\",\"line\":308,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/klines/{symbol}\"}]},{\"function\":\"chart_health\",\"line\":313,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/chart-health\"}]},{\"function\":\"state\",\"line\":347,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/state\"}]},{\"function\":\"save_holding\",\"line\":364,\"routes\":[{\"method\":\"POST\",\"path\":\"/api/holding\"}]},{\"function\":\"add_alert\",\"line\":377,\"routes\":[{\"method\":\"POST\",\"path\":\"/api/alert\"}]},{\"function\":\"delete_alert\",\"line\":391,\"routes\":[{\"method\":\"DELETE\",\"path\":\"/api/alert/{alert_id}\"}]},{\"function\":\"_gram1_chart_javascript_v4\",\"line\":410,\"routes\":[{\"method\":\"GET\",\"path\":\"/vendor/gram1-charts-v4.js\"}]},{\"function\":\"_gram1_chart_svg_endpoint_v4\",\"line\":467,\"routes\":[{\"method\":\"GET\",\"path\":\"/api/chart-svg/{symbol}\"}]}]},\"changes_made\":false,\"command_id\":\"GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z\",\"compose\":{\"compose_file\":\"compose.yaml\",\"config_rc\":0,\"ps_excerpt\":\"{\\\"Command\\\":\\\"\\\\\\\"uvicorn app:app --h…\\\\\\\"\\\",\\\"CreatedAt\\\":\\\"2026-08-01 16:21:30 +0300 MSK\\\",\\\"ExitCode\\\":0,\\\"Health\\\":\\\"\\\",\\\"ID\\\":\\\"dc202c12ba81\\\",\\\"Image\\\":\\\"crypto-portfolio-web\\\",\\\"Labels\\\":\\\"com.docker.compose.depends_on=,com.docker.compose.image=sha256:2a9339d475b2f4a01f060162f48a23b9590eb3ba4b036b46aad72adad540c40d,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=web,com.docker.compose.,com.docker.compose.oneoff=False,com.docker.compose.project=crypto-portfolio,com.docker.compose.version=2.26.1,com.docker.compose.container-number=1\\\",\\\"LocalVolumes\\\":\\\"0\\\",\\\"Mounts\\\":\\\"/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…\\\",\\\"Name\\\":\\\"crypto-portfolio\\\",\\\"Names\\\":\\\"crypto-portfolio\\\",\\\"Networks\\\":\\\"crypto-portfolio_default\\\",\\\"Ports\\\":\\\":8030-\\\\u003e8000/tcp\\\",\\\"Project\\\":\\\"crypto-portfolio\\\",\\\"Publishers\\\":[{\\\"URL\\\":\\\"\\\",\\\"TargetPort\\\":8000,\\\"PublishedPort\\\":8030,\\\"Protocol\\\":\\\"tcp\\\"}],\\\"RunningFor\\\":\\\"4 hours ago\\\",\\\"Service\\\":\\\"web\\\",\\\"Size\\\":\\\"0B\\\",\\\"State\\\":\\\"running\\\",\\\"Status\\\":\\\"Up 4 hours\\\"}\\n{\\\"Command\\\":\\\"\\\\\\\"python /app/worker.…\\\\\\\"\\\",\\\"CreatedAt\\\":\\\"2026-08-01 13:56:57 +0300 MSK\\\",\\\"ExitCode\\\":0,\\\"Health\\\":\\\"\\\",\\\"ID\\\":\\\"9a2ac999b94a\\\",\\\"Image\\\":\\\"crypto-portfolio-worker\\\",\\\"Labels\\\":\\\"com.docker.compose.project=crypto-portfolio,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.version=2.26.1,com.docker.compose.image=sha256:7ff5fdff7ab373e6b22dfa1a677b000d83ff9d021e4464915078e506781fd0ed,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=worker,com.docker.compose.,com.docker.compose.container-number=1,com.docker.compose.depends_on=,com.docker.compose.oneoff=False\\\",\\\"LocalVolumes\\\":\\\"0\\\",\\\"Mounts\\\":\\\"/opt/stacks/cr…,/opt/stacks/cr…\\\",\\\"Name\\\":\\\"crypto-portfolio-worker\\\",\\\"Names\\\":\\\"crypto-portfolio-worker\\\",\\\"Networks\\\":\\\"crypto-portfolio_default\\\",\\\"Ports\\\":\\\"\\\",\\\"Project\\\":\\\"crypto-portfolio\\\",\\\"Publishers\\\":null,\\\"RunningFor\\\":\\\"6 hours ago\\\",\\\"Service\\\":\\\"worker\\\",\\\"Size\\\":\\\"0B\\\",\\\"State\\\":\\\"running\\\",\\\"Status\\\":\\\"Up 6 hours\\\"}\",\"ps_rc\":0,\"services\":[\"web\",\"worker\"],\"services_rc\":0,\"stderr\":\"\"},\"control_plane_error\":null,\"duration_seconds\":0.043,\"files\":[{\"bytes\":32843,\"exists\":true,\"gid\":0,\"mode\":\"0o640\",\"path\":\"app.py\",\"sha256\":\"3b55b78c015173eea66b67afb8fb9371f0d7d2e38a7679d4c433749f4c07301d\",\"uid\":0},{\"bytes\":10300,\"exists\":true,\"gid\":0,\"mode\":\"0o640\",\"path\":\"worker.py\",\"sha256\":\"4a7b16ece826fe2d938f392db6b0b5ca028dcb6ff02fdff4ab431f114edf0e5c\",\"uid\":0},{\"bytes\":38036,\"exists\":true,\"gid\":0,\"mode\":\"0o644\",\"path\":\"templates/index.html\",\"sha256\":\"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2\",\"uid\":0},{\"bytes\":1233,\"exists\":true,\"gid\":0,\"mode\":\"0o644\",\"path\":\"compose.yaml\",\"sha256\":\"6d344e46b3de94ad86131a914df2ff3ff25e9d82f7ce0557cbffa468b8cdc1b8\",\"uid\":0}],\"gitea_changed\":false,\"hostname\":\"core-apps\",\"mutation_outcome\":\"NO_MUTATION\",\"plan\":{\"current_usdc_present_anywhere\":false,\"market_sections_found\":{\"alerts\":true,\"charts\":true,\"sharp_moves\":true,\"top_coin_cards\":true},\"mutation_scope_candidate\":[\"app.py\",\"templates/index.html\"],\"next_step\":\"ONE_ATOMIC_REVERSIBLE_MUTATION_AFTER_REVIEW_OF_THIS_EXACT_REPORT\",\"notes\":[\"Do not add USDC to the shared market COINS/KLINE/alert lists.\",\"Preserve HBAR/POL/AVAX/LINK market behavior unchanged.\",\"Expose an editable USDC quantity and average purchase price only if the current positions UI already uses those inputs.\",\"Portfolio totals must count USDC current value and cost basis; P/L for USDC follows the same row formula.\"],\"positions_section_found\":true,\"quantity_or_cost_basis_storage_found\":true,\"requested_behavior\":{\"add_usdc_to_positions_only\":true,\"exclude_from_alerts\":true,\"exclude_from_charts\":true,\"exclude_from_sharp_moves\":true,\"exclude_from_top_coin_cards\":true,\"include_usdc_in_total_invested\":true,\"include_usdc_in_total_portfolio_value\":true,\"usdc_market_price_policy\":\"fixed_1_usd_unless_existing_source_is_explicitly_better\"},\"required_architecture\":\"separate portfolio-position asset set from market-tracked asset set; USDC must exist only in the former\",\"worker_change_expected\":false},\"production_site_changed\":false,\"rc\":0,\"relevant_file_hits\":[{\"line\":9,\"path\":\"app.py\",\"text\":\"DB = DATA / \\\"portfolio.db\\\"\"},{\"line\":20,\"path\":\"app.py\",\"text\":\"c.execute(\\\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\\\")\"},{\"line\":89,\"path\":\"app.py\",\"text\":\"return {\\\"status\\\":\\\"OK\\\",\\\"type\\\":\\\"crypto-portfolio\\\",\\\"market_age_seconds\\\":age}\"},{\"line\":111,\"path\":\"app.py\",\"text\":\"with httpx.Client(timeout=6, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as client:\"},{\"line\":238,\"path\":\"app.py\",\"text\":\"headers={\\\"User-Agent\\\": \\\"crypto-portfolio/1.0\\\"},\"},{\"line\":350,\"path\":\"app.py\",\"text\":\"holdings = {r[\\\"coin_id\\\"]: {\\\"quantity\\\":r[\\\"quantity\\\"],\\\"avg_buy\\\":r[\\\"avg_buy\\\"]} for r in c.execute(\\\"SELECT * FROM holdings\\\")}\"},{\"line\":361,\"path\":\"app.py\",\"text\":\"return {\\\"coins\\\":COINS,\\\"market\\\":market_data(),\\\"holdings\\\":holdings,\\\"alerts\\\":alerts,\\\"history\\\":history}\"},{\"line\":363,\"path\":\"app.py\",\"text\":\"@app.post(\\\"/api/holding\\\")\"},{\"line\":364,\"path\":\"app.py\",\"text\":\"async def save_holding(request: Request):\"},{\"line\":370,\"path\":\"app.py\",\"text\":\"quantity = max(0.0, float(x.get(\\\"quantity\\\",0)))\"},{\"line\":373,\"path\":\"app.py\",\"text\":\"c.execute(\\\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\\\",(coin,quantity,avg_buy)); c.commit()\"},{\"line\":4,\"path\":\"compose.yaml\",\"text\":\"container_name: crypto-portfolio\"},{\"line\":29,\"path\":\"compose.yaml\",\"text\":\"container_name: crypto-portfolio-worker\"},{\"line\":7,\"path\":\"data/lightweight-charts.standalone.production.js\",\"text\":\"!function(){\\\"use strict\\\";const t={title:\\\"\\\",visible:!0,hitTestTolerance:3,lastValueVisible:!0,priceLineVisible:!0,priceLineSource:0,priceLineWidth:1,priceLineColor:\\\"\\\",priceLineStyle:2,baseLineVisible:!0,baseLineWidth:1,baseLineColor:\\\"#B2B5BE\\\",baseLineStyle:0,priceFormat:{type:\\\"price\\\",precision:2,minMove:.01}};var i,n;function s(t,i){const n=function(t,i){switch(t){case 0:default:return[];case 1:return[i,i];case 2:return[2*i,2*i];case 3:return[6*i,6*i];case 4:return[i,4*i]}}(i,t.lineWidth);return t.setLineDash(n),n}function e(t,i,n,s){t.beginPath();const e=t.lineWidth%2?.5:0;t.moveTo(n,i+e),t.lineTo(s,i+e),t.stroke()}function r(t,i){if(!t)throw new Error(\\\"Assertion failed\\\"+(i?\\\": \\\"+i:\\\"\\\"))}function h(t){if(void 0===t)throw new Error(\\\"Value is undefined\\\");return t}function a(t){if(null===t)throw new Error(\\\"Value is null\\\");return t}function l(t){return a(h(t))}!function(t){t[t.Simple=0]=\\\"Simple\\\",t[t.WithSteps=1]=\\\"WithSteps\\\",t[t.Curved=2]=\\\"Curved\\\"}(i||(i={})),function(t){t[t.Solid=0]=\\\"Solid\\\",\"},{\"line\":3,\"path\":\"templates/index.html\",\"text\":\"
Стоимость портфеля
Вложено
Прибыль / убыток0%
Обновление рынкакаждые 5 минут
\"},{\"line\":9,\"path\":\"templates/index.html\",\"text\":\"\"},{\"line\":109,\"text\":\"\"},{\"line\":217,\"text\":\"\"},{\"line\":231,\"text\":\"\"},{\"line\":232,\"text\":\"\"}],\"sha256\":\"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2\"},\"worker\":{\"assignments\":[{\"end_line\":8,\"line\":8,\"literal\":null,\"names\":[\"MARKET\"],\"source\":\"MARKET = DATA / \\\"market.json\\\"\"},{\"end_line\":11,\"line\":11,\"literal\":{\"avalanche-2\":\"AVAX\",\"chainlink\":\"LINK\",\"hedera-hashgraph\":\"HBAR\",\"polygon-ecosystem-token\":\"POL\"},\"names\":[\"COINS\"],\"source\":\"COINS = {\\\"hedera-hashgraph\\\": \\\"HBAR\\\", \\\"polygon-ecosystem-token\\\": \\\"POL\\\", \\\"avalanche-2\\\": \\\"AVAX\\\", \\\"chainlink\\\": \\\"LINK\\\"}\"},{\"end_line\":111,\"line\":111,\"literal\":null,\"names\":[\"quantity\"],\"source\":\"quantity = float(r[\\\"quantity\\\"])\"},{\"end_line\":112,\"line\":112,\"literal\":null,\"names\":[\"avg\"],\"source\":\"avg = float(r[\\\"avg_buy\\\"])\"},{\"end_line\":114,\"line\":114,\"literal\":null,\"names\":[\"invested\"],\"source\":\"invested = quantity * avg\"},{\"end_line\":123,\"line\":123,\"literal\":null,\"names\":[\"coin\"],\"source\":\"coin = market[\\\"coins\\\"].get(a[\\\"coin_id\\\"])\"},{\"end_line\":128,\"line\":128,\"literal\":null,\"names\":[\"price\"],\"source\":\"price = float(coin[\\\"price\\\"])\"},{\"end_line\":179,\"line\":179,\"literal\":null,\"names\":[\"market\"],\"source\":\"market = fetch_market()\"}],\"relevant_functions\":[{\"end_line\":23,\"line\":14,\"name\":\"connect\",\"source\":\"def connect():\\n c = sqlite3.connect(DB, timeout=15)\\n c.row_factory = sqlite3.Row\\n c.execute(\\\"PRAGMA journal_mode=WAL\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\\\")\\n c.execute(\\\"CREATE TABLE IF NOT EXISTS market_history(ts INTEGER NOT NULL, coin_id TEXT NOT NULL, price REAL NOT NULL, market_cap REAL NOT NULL, volume REAL NOT NULL DEFAULT 0, PRIMARY KEY(ts,coin_id))\\\")\\n c.execute(\\\"CREATE INDEX IF NOT EXISTS idx_market_history_coin_ts ON market_history(coin_id,ts)\\\")\\n c.commit()\\n return c\"},{\"end_line\":75,\"line\":41,\"name\":\"fetch_market\",\"source\":\"def fetch_market():\\n params = {\\\"vs_currency\\\":\\\"usd\\\",\\\"ids\\\":\\\",\\\".join(COINS),\\\"order\\\":\\\"market_cap_desc\\\",\\\"per_page\\\":50,\\\"page\\\":1,\\\"sparkline\\\":\\\"true\\\",\\\"price_change_percentage\\\":\\\"1h,24h,7d\\\"}\\n with httpx.Client(timeout=25, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as client:\\n r = client.get(\\\"https://api.coingecko.com/api/v3/coins/markets\\\", params=params)\\n r.raise_for_status()\\n rows = r.json()\\n now = int(time.time())\\n usd_rub = 0.0\\n try:\\n with open(MARKET, \\\"r\\\", encoding=\\\"utf-8\\\") as previous_file:\\n previous_rate = float((json.load(previous_file) or {}).get(\\\"usd_rub\\\") or 0)\\n if 40 <= previous_rate <= 200:\\n usd_rub = previous_rate\\n except Exception:\\n pass\\n try:\\n with httpx.Client(timeout=15, headers={\\\"User-Agent\\\":\\\"Gram-Crypto-Portfolio/1.0\\\"}) as fx_client:\\n fx_data = fx_client.get(\\\"https://api.coingecko.com/api/v3/simple/price\\\", params={\\\"ids\\\":\\\"bitcoin\\\",\\\"vs_currencies\\\":\\\"usd,rub\\\"}).json()[\\\"bitcoin\\\"]\\n candidate = float(fx_data[\\\"rub\\\"]) / float(fx_data[\\\"usd\\\"])\\n if 40 <= candidate <= 200:\\n usd_rub = candidate\\n except Exception:\\n pass\\n if not 40 <= usd_rub <= 200:\\n usd_rub = 77.025093\\n result = {\\\"updated_at\\\":now,\\\"currency\\\":\\\"usd\\\",\\\"usd_rub\\\":usd_rub,\\\"coins\\\":{}}\\n\\n for x in rows:\\n cid = str(x.get(\\\"id\\\",\\\"\\\"))\\n if cid not in COINS:\\n continue\\n result[\\\"coins\\\"][cid] = {\\\"symbol\\\":COINS[cid],\\\"name\\\":x.get(\\\"name\\\"),\\\"price\\\":float(x.get(\\\"current_price\\\") or 0),\\\"market_cap\\\":float(x.get(\\\"market_cap\\\") or 0),\\\"rank\\\":x.get(\\\"market_cap_rank\\\"),\\\"volume\\\":float(x.get(\\\"total_volume\\\") or 0),\\\"high_24h\\\":x.get(\\\"high_24h\\\"),\\\"low_24h\\\":x.get(\\\"low_24h\\\"),\\\"change_1h\\\":x.get(\\\"price_change_percentage_1h_in_currency\\\"),\\\"change_24h\\\":x.get(\\\"price_change_percentage_24h_in_currency\\\"),\\\"change_7d\\\":x.get(\\\"price_change_percentage_7d_in_currency\\\"),\\\"sparkline_7d\\\":((x.get(\\\"sparkline_in_7d\\\") or {}).get(\\\"price\\\") or []),\\\"last_updated\\\":x.get(\\\"last_updated\\\")}\\n if len(result[\\\"coins\\\"]) != len(COINS):\\n raise RuntimeError(\\\"incomplete market response\\\")\\n return result\"},{\"end_line\":79,\"line\":77,\"name\":\"reference_price\",\"source\":\"def reference_price(c, coin_id, target_ts):\\n r = c.execute(\\\"SELECT price FROM market_history WHERE coin_id=? AND ts<=? ORDER BY ts DESC LIMIT 1\\\",(coin_id,target_ts)).fetchone()\\n return float(r[\\\"price\\\"]) if r else None\"},{\"end_line\":117,\"line\":107,\"name\":\"portfolio_text\",\"source\":\"def portfolio_text(c, coin_id, price):\\n r = c.execute(\\\"SELECT quantity,avg_buy FROM holdings WHERE coin_id=?\\\",(coin_id,)).fetchone()\\n if not r:\\n return \\\"\\\"\\n quantity = float(r[\\\"quantity\\\"])\\n avg = float(r[\\\"avg_buy\\\"])\\n value = quantity * price\\n invested = quantity * avg\\n pnl = value - invested\\n pct = pnl / invested * 100 if invested else 0\\n return \\\"\\\\nКоличество монет: {:.8f}\\\\nТекущая стоимость позиции: ${:,.2f}\\\\nПрибыль или убыток: ${:,.2f} ({:+.2f}%)\\\\n\\\".format(quantity,value,pnl,pct)\"},{\"end_line\":175,\"line\":119,\"name\":\"evaluate\",\"source\":\"def evaluate(c, market):\\n now = int(time.time())\\n triggered = 0\\n for a in c.execute(\\\"SELECT * FROM alerts WHERE enabled=1 ORDER BY id\\\").fetchall():\\n coin = market[\\\"coins\\\"].get(a[\\\"coin_id\\\"])\\n if not coin:\\n continue\\n kind = a[\\\"kind\\\"]\\n threshold = float(a[\\\"threshold\\\"])\\n price = float(coin[\\\"price\\\"])\\n cap = float(coin[\\\"market_cap\\\"])\\n latched = int(a[\\\"latched\\\"])\\n fired = False\\n reset = False\\n description = \\\"\\\"\\n if kind == \\\"price_above\\\":\\n fired = price >= threshold\\n reset = price < threshold * 0.99\\n description = \\\"Цена {} достигла ${:,.8f}, порог выше или равно ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],price,threshold)\\n elif kind == \\\"price_below\\\":\\n fired = price <= threshold\\n reset = price > threshold * 1.01\\n description = \\\"Цена {} снизилась до ${:,.8f}, порог ниже или равно ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],price,threshold)\\n elif kind == \\\"cap_above\\\":\\n fired = cap >= threshold\\n reset = cap < threshold * 0.99\\n description = \\\"Капитализация {} достигла ${:,.0f}, порог выше или равно ${:,.0f}.\\\".format(coin[\\\"symbol\\\"],cap,threshold)\\n elif kind == \\\"cap_below\\\":\\n fired = cap <= threshold\\n reset = cap > threshold * 1.01\\n description = \\\"Капитализация {} снизилась до ${:,.0f}, порог ниже или равно ${:,.0f}.\\\".format(coin[\\\"symbol\\\"],cap,threshold)\\n elif kind in {\\\"pump\\\",\\\"dump\\\"}:\\n old = reference_price(c,a[\\\"coin_id\\\"],now-int(a[\\\"window_seconds\\\"]))\\n if old and old > 0:\\n change = (price / old - 1) * 100\\n if kind == \\\"pump\\\":\\n fired = change >= threshold\\n reset = change < threshold * 0.5\\n description = \\\"Резкий памп {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],change,int(a[\\\"window_seconds\\\"])//60,old,price)\\n else:\\n fired = change <= -threshold\\n reset = change > -threshold * 0.5\\n description = \\\"Резкое падение {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\\\".format(coin[\\\"symbol\\\"],change,int(a[\\\"window_seconds\\\"])//60,old,price)\\n if reset and latched:\\n c.execute(\\\"UPDATE alerts SET latched=0 WHERE id=?\\\",\"},{\"end_line\":190,\"line\":177,\"name\":\"cycle\",\"source\":\"def cycle():\\n DATA.mkdir(parents=True, exist_ok=True)\\n market = fetch_market()\\n now = int(market[\\\"updated_at\\\"])\\n with connect() as c:\\n for cid, x in market[\\\"coins\\\"].items():\\n c.execute(\\\"INSERT OR REPLACE INTO market_history(ts,coin_id,price,market_cap,volume) VALUES(?,?,?,?,?)\\\",(now,cid,x[\\\"price\\\"],x[\\\"market_cap\\\"],x[\\\"volume\\\"]))\\n c.execute(\\\"DELETE FROM market_history WHERE ts","names":["_GRAM1_CHART_JS_B64"],"source":"_GRAM1_CHART_JS_B64 = \"\""},{"end_line":407,"line":407,"literal":["AVAX","POL","HBAR","LINK"],"names":["_GRAM1_CHART_SYMBOLS"],"source":"_GRAM1_CHART_SYMBOLS = {\"HBAR\", \"POL\", \"AVAX\", \"LINK\"}"},{"end_line":459,"line":459,"literal":null,"names":["price"],"source":"price=hi-span*n/4"},{"end_line":470,"line":470,"literal":null,"names":["symbol"],"source":"symbol=symbol.upper()"}],"relevant_functions":[{"end_line":23,"line":16,"name":"db","source":"def db():\n c = sqlite3.connect(DB, timeout=10)\n c.row_factory = sqlite3.Row\n c.execute(\"PRAGMA journal_mode=WAL\")\n c.execute(\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\")\n c.execute(\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\")\n c.commit()\n return c"},{"end_line":47,"line":42,"name":"market_data","source":"def market_data():\n try:\n obj = json.loads(MARKET.read_text())\n return obj if isinstance(obj, dict) else {}\n except Exception:\n return {}"},{"end_line":56,"line":53,"name":"login_page","source":"def login_page(request: Request):\n if request.session.get(\"auth\"):\n return RedirectResponse(\"/\", 303)\n return HTMLResponse(\"\"\"Gram Crypto

Gram Crypto

https://gram1.ru/

Введите пароль для доступа к криптопортфелю.

\"\"\" + (\"Неверный пароль\" if request.query_params.get(\"bad\") else \"\") + \"\"\"
\"\"\")"},{"end_line":89,"line":85,"name":"health","source":"def health():\n age = None\n if MARKET.exists():\n age = int(time.time() - MARKET.stat().st_mtime)\n return {\"status\":\"OK\",\"type\":\"crypto-portfolio\",\"market_age_seconds\":age}"},{"end_line":134,"line":93,"name":"coin_icon","source":"def coin_icon(coin_id: str):\n import html\n import os\n import re\n from pathlib import Path\n import httpx\n from fastapi import HTTPException\n from fastapi.responses import Response\n if not re.fullmatch(r\"[a-z0-9-]{1,64}\", coin_id):\n raise HTTPException(status_code=404)\n cache_dir = Path(\"/data/coin-icons\")\n cache_dir.mkdir(parents=True, exist_ok=True)\n body_path = cache_dir / (coin_id + \".img\")\n type_path = cache_dir / (coin_id + \".type\")\n if body_path.exists() and body_path.stat().st_size > 128:\n media_type = type_path.read_text().strip() if type_path.exists() else \"image/png\"\n return Response(body_path.read_bytes(), media_type=media_type, headers={\"Cache-Control\":\"public, max-age=86400\"})\n try:\n with httpx.Client(timeout=6, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as client:\n response = client.get(\"https://api.coingecko.com/api/v3/coins/markets\", params={\"vs_currency\":\"usd\",\"ids\":coin_id,\"sparkline\":\"false\"})\n response.raise_for_status()\n rows = response.json()\n if not rows or not rows[0].get(\"image\"):\n raise ValueError(\"image URL missing\")\n image = client.get(rows[0][\"image\"])\n image.raise_for_status()\n media_type = (image.headers.get(\"content-type\") or \"image/png\").split(\";\", 1)[0]\n if not media_type.startswith(\"image/\") or len(image.content) <= 128:\n raise ValueError(\"invalid image\")\n tmp_path = body_path.with_suffix(\".tmp\")\n tmp_path.write_bytes(image.content)\n os.replace(tmp_path, body_path)\n type_path.write_text(media_type)\n return Response(image.content, media_type=media_type, headers={\"Cache-Control\":\"public, max-age=86400\"})\n except Exception:\n symbols = {\"bitcoin\":\"₿\",\"ethereum\":\"Ξ\",\"solana\":\"S\",\"binancecoin\":\"BNB\",\"ripple\":\"XRP\",\"cardano\":\"ADA\",\"hedera-hashgraph\":\"HBAR\",\"hedera\":\"HBAR\",\"avalanche-2\":\"AVAX\",\"chainlink\":\"LINK\",\"toncoin\":\"TON\",\"dogecoin\":\"Ð\"}\n label = symbols.get(coin_id, coin_id[:3].upper())\n palette = [\"#f7931a\",\"#627eea\",\"#14f195\",\"#f3ba2f\",\"#23292f\",\"#3468d4\",\"#6f5af5\"]\n color = palette[sum(ord(ch) for ch in coin_id) % len(palette)]\n safe_label = html.escape(label)\n svg = f'= 2\n )"},{"end_line":183,"line":161,"name":"_load_kline_cache","source":"def _load_kline_cache():\n if not KLINE_CACHE_FILE.is_file():\n return\n try:\n payload = json.loads(KLINE_CACHE_FILE.read_text())\n entries = payload.get(\"entries\", {})\n now = time.time()\n for name, item in entries.items():\n parts = name.split(\":\")\n if len(parts) == 3:\n symbol, interval, limit_text = parts\n elif len(parts) == 2:\n symbol, limit_text = parts\n interval = \"1d\"\n else:\n continue\n limit = int(limit_text)\n saved_at = float(item.get(\"saved_at\", 0))\n result = item.get(\"result\")\n if now - saved_at <= KLINE_CACHE_MAX_STALE and _valid_kline_result(result):\n KLINE_CACHE[(symbol, interval, limit)] = (saved_at, result)\n except Exception:\n return"},{"end_line":201,"line":185,"name":"_save_kline_cache","source":"def _save_kline_cache():\n with KLINE_CACHE_WRITE_LOCK:\n entries = {}\n for key, value in list(KLINE_CACHE.items()):\n if len(key) == 3:\n symbol, interval, limit = key\n else:\n symbol, limit = key\n interval = \"1d\"\n saved_at, result = value\n if _valid_kline_result(result):\n entries[symbol + \":\" + interval + \":\" + str(limit)] = {\"saved_at\": saved_at, \"result\": result}\n payload = {\"version\": 1, \"saved_at\": int(time.time()), \"entries\": entries}\n temporary = KLINE_CACHE_FILE.with_suffix(\".json.tmp\")\n temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(\",\", \":\")))\n os.replace(temporary, KLINE_CACHE_FILE)\n os.chmod(KLINE_CACHE_FILE, 0o644)"},{"end_line":305,"line":205,"name":"fetch_klines","source":"def fetch_klines(symbol, limit=365, interval=\"1d\"):\n key = str(symbol).upper()\n pair = KLINE_SYMBOLS.get(key)\n if not pair:\n raise HTTPException(404, \"unknown symbol\")\n interval = str(interval or \"1d\")\n if interval not in {\"1d\", \"1M\"}:\n raise HTTPException(400, \"unsupported interval\")\n limit = max(1, min(int(limit), 1000))\n if interval == \"1d\":\n limit = max(30, limit)\n cache_key = (key, interval, limit)\n now = time.time()\n cached = KLINE_CACHE.get(cache_key)\n if cached and now - cached[0] < KLINE_CACHE_TTL:\n result = dict(cached[1])\n result[\"source\"] = \"cache\"\n result[\"stale\"] = False\n result[\"cache_age_seconds\"] = int(now - cached[0])\n return result\n stale = cached if cached and now - cached[0] <= KLINE_CACHE_MAX_STALE else None\n remaining = limit\n end_time = None\n collected = []\n try:\n while remaining > 0:\n page_limit = min(1000 if interval == \"1M\" else 100, remaining)\n params = {\"symbol\": pair, \"interval\": interval, \"limit\": page_limit}\n if end_time is not None:\n params[\"endTime\"] = end_time\n query = urllib.parse.urlencode(params)\n request = urllib.request.Request(\n \"https://data-api.binance.vision/api/v3/klines?\" + query,\n headers={\"User-Agent\": \"crypto-portfolio/1.0\"},\n )\n rows = None\n last_error = None\n for attempt in range(3):\n try:\n with urllib.request.urlopen(request, timeout=20) as response:\n rows = json.load(response)\n break\n except Exception as exc:\n last_error = exc\n if attempt < 2:\n time.sleep(0.5 * (attempt + 1))\n if rows is None:\n raise last_error\n if not isinstance(rows, list) or not rows:\n break\n collected.extend(rows)\n remaining -= len(rows)\n if len(rows) < page_limit:\n break\n end_time = int(rows[0][0]) - 1\n except Exception as exc:\n if stale:\n result = dict(stale[1])\n result[\"source\"] = \"stale-cache\"\n result[\"stale\"] = True\n result[\"cache_age_seconds\"] = int(now - stale[0])\n "},{"end_line":310,"line":308,"name":"klines","source":"def klines(symbol: str, request: Request, limit: int = 365, interval: str = \"1d\"):\n require_login(request)\n return fetch_klines(symbol, limit, interval)"},{"end_line":344,"line":313,"name":"chart_health","source":"def chart_health():\n now = time.time()\n details = {}\n bad = 0\n for symbol in (\"HBAR\", \"POL\", \"AVAX\", \"LINK\"):\n entry = KLINE_CACHE.get((symbol, \"1M\", 1000)) or KLINE_CACHE.get((symbol, \"1d\", 365))\n if not entry or not _valid_kline_result(entry[1]):\n details[symbol] = {\"status\": \"ERROR\", \"candles\": 0, \"cache_age_seconds\": None}\n bad += 1\n continue\n age = int(now - entry[0])\n expired = age > KLINE_CACHE_MAX_STALE\n details[symbol] = {\n \"status\": \"ERROR\" if expired else \"OK\",\n \"candles\": len(entry[1][\"candles\"]),\n \"cache_age_seconds\": age,\n \"stale\": age > KLINE_CACHE_TTL,\n \"source\": entry[1].get(\"source\", \"cache\"),\n }\n if expired:\n bad += 1\n library_ok = LIGHTWEIGHT_JS.is_file() and LIGHTWEIGHT_JS.stat().st_size > 100000\n if not library_ok:\n bad += 1\n payload = {\n \"status\": \"OK\" if bad == 0 else \"ERROR\",\n \"library_ok\": library_ok,\n \"disk_cache_present\": KLINE_CACHE_FILE.is_file(),\n \"symbols\": details,\n \"bad\": bad,\n }\n return JSONResponse(payload, status_code=200 if bad == 0 else 503)"},{"end_line":361,"line":347,"name":"state","source":"def state(request: Request):\n require_login(request)\n with db() as c:\n holdings = {r[\"coin_id\"]: {\"quantity\":r[\"quantity\"],\"avg_buy\":r[\"avg_buy\"]} for r in c.execute(\"SELECT * FROM holdings\")}\n alerts = [dict(r) for r in c.execute(\"SELECT * FROM alerts ORDER BY id DESC\")]\n history = {}\n z = {r[\"name\"] for r in c.execute(\"PRAGMA table_info(market_history)\")}\n cc = next((x for x in (\"coin_id\",\"coin\",\"asset_id\") if x in z),None); pc = next((x for x in (\"price\",\"current_price\",\"usd_price\",\"value\") if x in z),None); tc = next((x for x in (\"ts\",\"timestamp\",\"time\",\"created_at\",\"updated_at\") if x in z),None)\n if cc and pc and tc:\n q = f'SELECT \"{pc}\" p FROM market_history WHERE \"{cc}\"=? ORDER BY \"{tc}\"'\n for cid in COINS:\n v=[float(r[\"p\"]) for r in c.execute(q,(cid,)) if r[\"p\"] is not None]\n history[cid]=v if len(v)<=1200 else [v[int(i*(len(v)-1)/1199)] for i in range(1200)]\n # CRYPTO_HISTORY_STATE_V2\n return {\"coins\":COINS,\"market\":market_data(),\"holdings\":holdings,\"alerts\":alerts,\"history\":history}"},{"end_line":374,"line":364,"name":"save_holding","source":"async def save_holding(request: Request):\n require_login(request)\n x = await request.json()\n coin = str(x.get(\"coin_id\",\"\"))\n if coin not in COINS:\n raise HTTPException(400,\"bad coin\")\n quantity = max(0.0, float(x.get(\"quantity\",0)))\n avg_buy = max(0.0, float(x.get(\"avg_buy\",0)))\n with db() as c:\n c.execute(\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\",(coin,quantity,avg_buy)); c.commit()\n return {\"ok\":True}"},{"end_line":388,"line":377,"name":"add_alert","source":"async def add_alert(request: Request):\n require_login(request)\n x = await request.json()\n coin = str(x.get(\"coin_id\",\"\")); kind = str(x.get(\"kind\",\"\"))\n if coin not in COINS or kind not in {\"price_above\",\"price_below\",\"cap_above\",\"cap_below\",\"pump\",\"dump\"}:\n raise HTTPException(400,\"bad rule\")\n threshold = float(x.get(\"threshold\",0)); window = int(x.get(\"window_seconds\",0)); email = str(x.get(\"email\",\"\")).strip()\n if threshold <= 0 or \"@\" not in email or (kind in {\"pump\",\"dump\"} and window not in {300,900,1800,3600,14400,86400}):\n raise HTTPException(400,\"bad values\")\n with db() as c:\n cur = c.execute(\"INSERT INTO alerts(coin_id,kind,threshold,window_seconds,email) VALUES(?,?,?,?,?)\",(coin,kind,threshold,window,email)); c.commit()\n return {\"ok\":True,\"id\":cur.lastrowid}"},{"end_line":395,"line":391,"name":"delete_alert","source":"def delete_alert(alert_id: int, request: Request):\n require_login(request)\n with db() as c:\n c.execute(\"DELETE FROM alerts WHERE id=?\",(alert_id,)); c.commit()\n return {\"ok\":True}"},{"end_line":411,"line":410,"name":"_gram1_chart_javascript_v4","source":"def _gram1_chart_javascript_v4():\n return _Gram1Response(content=_gram1_base64.b64decode(_GRAM1_CHART_JS_B64), media_type=\"application/javascript\", headers={\"Cache-Control\":\"no-store\"})"},{"end_line":450,"line":413,"name":"_gram1_chart_data_v4","source":"async def _gram1_chart_data_v4(symbol: str, request: _Gram1Request):\n fn = globals().get(\"klines\")\n if fn is None:\n raise RuntimeError(\"klines route function not found\")\n kwargs = {}\n for name, parameter in _gram1_inspect.signature(fn).parameters.items():\n lname = name.lower()\n if lname in {\"symbol\", \"coin\", \"ticker\"}: kwargs[name] = symbol\n elif lname == \"request\": kwargs[name] = request\n elif lname == \"limit\": kwargs[name] = 1000\n elif lname == \"interval\": kwargs[name] = \"1M\"\n elif parameter.default is _gram1_inspect.Parameter.empty:\n raise RuntimeError(\"unsupported required klines parameter: \" + name)\n value = fn(**kwargs)\n if _gram1_inspect.isawaitable(value): value = await value\n if hasattr(value, \"body\"):\n value = _gram1_json.loads(bytes(value.body).decode(\"utf-8\"))\n if not isinstance(value, dict):\n raise RuntimeError(\"klines result is not an object\")\n if value.get(\"status\") not in (None, \"OK\"):\n raise RuntimeError(str(value.get(\"error\") or value.get(\"message\") or value.get(\"status\")))\n candles = value.get(\"candles\")\n if not isinstance(candles, list) and isinstance(value.get(\"result\"), dict): candles = value[\"result\"].get(\"candles\")\n if not isinstance(candles, list) or not candles:\n raise RuntimeError(\"klines returned no candles\")\n rows = []\n for item in candles:\n try:\n if isinstance(item, dict):\n t=item.get(\"time\",item.get(\"open_time\",item.get(\"openTime\")));o=float(item[\"open\"]);h=float(item[\"high\"]);l=float(item[\"low\"]);c=float(item[\"close\"])\n else:\n t=item[0];o=float(item[1]);h=float(item[2]);l=float(item[3]);c=float(item[4])\n if h < l: continue\n rows.append((str(t),o,h,l,c))\n except Exception:\n continue\n if not rows: raise RuntimeError(\"no valid OHLC rows\")\n return rows[-180:]"},{"end_line":464,"line":452,"name":"_gram1_chart_svg_v4","source":"def _gram1_chart_svg_v4(symbol: str, rows):\n width,height,left,right,top,bottom=1200,430,72,28,24,42\n lo=min(r[3] for r in rows);hi=max(r[2] for r in rows);span=max(hi-lo,abs(hi)*1e-9,1e-12)\n plot_w=width-left-right;plot_h=height-top-bottom;step=plot_w/max(len(rows),1);body=max(2.0,min(10.0,step*0.58))\n def y(v): return top+(hi-v)/span*plot_h\n parts=[f'', '']\n for n in range(5):\n yy=top+plot_h*n/4;price=hi-span*n/4;parts.append(f'');parts.append(f'{price:.8g}')\n for i,(_,o,h,l,c) in enumerate(rows):\n x=left+step*(i+.5);color=\"#63e6be\" if c>=o else \"#ff6b81\";yo=y(o);yc=y(c);yh=y(h);yl=y(l);rh=max(1.2,abs(yc-yo));ry=min(yo,yc)\n parts.append(f'');parts.append(f'')\n parts.append(f'{_gram1_html.escape(symbol)} / USDT · 1M · {len(rows)} свечей')\n return ''.join(parts)"},{"end_line":480,"line":467,"name":"_gram1_chart_svg_endpoint_v4","source":"async def _gram1_chart_svg_endpoint_v4(symbol: str, request: _Gram1Request):\n if not request.session.get(\"auth\"):\n return _Gram1Response(content=\"Требуется вход\", status_code=401, media_type=\"text/plain; charset=utf-8\")\n symbol=symbol.upper()\n if symbol not in _GRAM1_CHART_SYMBOLS:\n return _Gram1Response(content=\"Неизвестная монета\", status_code=404, media_type=\"text/plain; charset=utf-8\")\n try:\n rows=await _gram1_chart_data_v4(symbol,request)\n svg=_gram1_chart_svg_v4(symbol,rows)\n return _Gram1Response(content=svg,media_type=\"image/svg+xml\",headers={\"Cache-Control\":\"no-store\"})\n except Exception as exc:\n msg=_gram1_html.escape(str(exc))\n svg=f'Ошибка серверного графика {symbol}: {msg}'\n return _Gram1Response(content=svg,status_code=503,media_type=\"image/svg+xml\",headers={\"Cache-Control\":\"no-store\"})"}],"routes":[{"function":"login_page","line":53,"routes":[{"method":"GET","path":"/login"}]},{"function":"login","line":59,"routes":[{"method":"POST","path":"/login"}]},{"function":"logout","line":74,"routes":[{"method":"POST","path":"/logout"}]},{"function":"home","line":79,"routes":[{"method":"GET","path":"/"}]},{"function":"health","line":85,"routes":[{"method":"GET","path":"/health"}]},{"function":"coin_icon","line":93,"routes":[{"method":"GET","path":"/coin-icon/{coin_id}"}]},{"function":"lightweight_charts_js","line":142,"routes":[{"method":"GET","path":"/vendor/lightweight-charts.js"}]},{"function":"klines","line":308,"routes":[{"method":"GET","path":"/api/klines/{symbol}"}]},{"function":"chart_health","line":313,"routes":[{"method":"GET","path":"/api/chart-health"}]},{"function":"state","line":347,"routes":[{"method":"GET","path":"/api/state"}]},{"function":"save_holding","line":364,"routes":[{"method":"POST","path":"/api/holding"}]},{"function":"add_alert","line":377,"routes":[{"method":"POST","path":"/api/alert"}]},{"function":"delete_alert","line":391,"routes":[{"method":"DELETE","path":"/api/alert/{alert_id}"}]},{"function":"_gram1_chart_javascript_v4","line":410,"routes":[{"method":"GET","path":"/vendor/gram1-charts-v4.js"}]},{"function":"_gram1_chart_svg_endpoint_v4","line":467,"routes":[{"method":"GET","path":"/api/chart-svg/{symbol}"}]}]},"changes_made":false,"command_id":"GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z","compose":{"compose_file":"compose.yaml","config_rc":0,"ps_excerpt":"{\"Command\":\"\\\"uvicorn app:app --h…\\\"\",\"CreatedAt\":\"2026-08-01 16:21:30 +0300 MSK\",\"ExitCode\":0,\"Health\":\"\",\"ID\":\"dc202c12ba81\",\"Image\":\"crypto-portfolio-web\",\"Labels\":\"com.docker.compose.depends_on=,com.docker.compose.image=sha256:2a9339d475b2f4a01f060162f48a23b9590eb3ba4b036b46aad72adad540c40d,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=web,com.docker.compose.,com.docker.compose.oneoff=False,com.docker.compose.project=crypto-portfolio,com.docker.compose.version=2.26.1,com.docker.compose.container-number=1\",\"LocalVolumes\":\"0\",\"Mounts\":\"/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…,/opt/stacks/cr…\",\"Name\":\"crypto-portfolio\",\"Names\":\"crypto-portfolio\",\"Networks\":\"crypto-portfolio_default\",\"Ports\":\":8030-\\u003e8000/tcp\",\"Project\":\"crypto-portfolio\",\"Publishers\":[{\"URL\":\"\",\"TargetPort\":8000,\"PublishedPort\":8030,\"Protocol\":\"tcp\"}],\"RunningFor\":\"4 hours ago\",\"Service\":\"web\",\"Size\":\"0B\",\"State\":\"running\",\"Status\":\"Up 4 hours\"}\n{\"Command\":\"\\\"python /app/worker.…\\\"\",\"CreatedAt\":\"2026-08-01 13:56:57 +0300 MSK\",\"ExitCode\":0,\"Health\":\"\",\"ID\":\"9a2ac999b94a\",\"Image\":\"crypto-portfolio-worker\",\"Labels\":\"com.docker.compose.project=crypto-portfolio,com.docker.compose.project.config_files=/opt/stacks/crypto-portfolio/compose.yaml,com.docker.compose.version=2.26.1,com.docker.compose.image=sha256:7ff5fdff7ab373e6b22dfa1a677b000d83ff9d021e4464915078e506781fd0ed,com.docker.compose.project.working_dir=/opt/stacks/crypto-portfolio,com.docker.compose.,com.docker.compose.service=worker,com.docker.compose.,com.docker.compose.container-number=1,com.docker.compose.depends_on=,com.docker.compose.oneoff=False\",\"LocalVolumes\":\"0\",\"Mounts\":\"/opt/stacks/cr…,/opt/stacks/cr…\",\"Name\":\"crypto-portfolio-worker\",\"Names\":\"crypto-portfolio-worker\",\"Networks\":\"crypto-portfolio_default\",\"Ports\":\"\",\"Project\":\"crypto-portfolio\",\"Publishers\":null,\"RunningFor\":\"6 hours ago\",\"Service\":\"worker\",\"Size\":\"0B\",\"State\":\"running\",\"Status\":\"Up 6 hours\"}","ps_rc":0,"services":["web","worker"],"services_rc":0,"stderr":""},"control_plane_error":null,"duration_seconds":0.043,"files":[{"bytes":32843,"exists":true,"gid":0,"mode":"0o640","path":"app.py","sha256":"3b55b78c015173eea66b67afb8fb9371f0d7d2e38a7679d4c433749f4c07301d","uid":0},{"bytes":10300,"exists":true,"gid":0,"mode":"0o640","path":"worker.py","sha256":"4a7b16ece826fe2d938f392db6b0b5ca028dcb6ff02fdff4ab431f114edf0e5c","uid":0},{"bytes":38036,"exists":true,"gid":0,"mode":"0o644","path":"templates/index.html","sha256":"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2","uid":0},{"bytes":1233,"exists":true,"gid":0,"mode":"0o644","path":"compose.yaml","sha256":"6d344e46b3de94ad86131a914df2ff3ff25e9d82f7ce0557cbffa468b8cdc1b8","uid":0}],"gitea_changed":false,"hostname":"core-apps","mutation_outcome":"NO_MUTATION","plan":{"current_usdc_present_anywhere":false,"market_sections_found":{"alerts":true,"charts":true,"sharp_moves":true,"top_coin_cards":true},"mutation_scope_candidate":["app.py","templates/index.html"],"next_step":"ONE_ATOMIC_REVERSIBLE_MUTATION_AFTER_REVIEW_OF_THIS_EXACT_REPORT","notes":["Do not add USDC to the shared market COINS/KLINE/alert lists.","Preserve HBAR/POL/AVAX/LINK market behavior unchanged.","Expose an editable USDC quantity and average purchase price only if the current positions UI already uses those inputs.","Portfolio totals must count USDC current value and cost basis; P/L for USDC follows the same row formula."],"positions_section_found":true,"quantity_or_cost_basis_storage_found":true,"requested_behavior":{"add_usdc_to_positions_only":true,"exclude_from_alerts":true,"exclude_from_charts":true,"exclude_from_sharp_moves":true,"exclude_from_top_coin_cards":true,"include_usdc_in_total_invested":true,"include_usdc_in_total_portfolio_value":true,"usdc_market_price_policy":"fixed_1_usd_unless_existing_source_is_explicitly_better"},"required_architecture":"separate portfolio-position asset set from market-tracked asset set; USDC must exist only in the former","worker_change_expected":false},"production_site_changed":false,"rc":0,"relevant_file_hits":[{"line":9,"path":"app.py","text":"DB = DATA / \"portfolio.db\""},{"line":20,"path":"app.py","text":"c.execute(\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\")"},{"line":89,"path":"app.py","text":"return {\"status\":\"OK\",\"type\":\"crypto-portfolio\",\"market_age_seconds\":age}"},{"line":111,"path":"app.py","text":"with httpx.Client(timeout=6, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as client:"},{"line":238,"path":"app.py","text":"headers={\"User-Agent\": \"crypto-portfolio/1.0\"},"},{"line":350,"path":"app.py","text":"holdings = {r[\"coin_id\"]: {\"quantity\":r[\"quantity\"],\"avg_buy\":r[\"avg_buy\"]} for r in c.execute(\"SELECT * FROM holdings\")}"},{"line":361,"path":"app.py","text":"return {\"coins\":COINS,\"market\":market_data(),\"holdings\":holdings,\"alerts\":alerts,\"history\":history}"},{"line":363,"path":"app.py","text":"@app.post(\"/api/holding\")"},{"line":364,"path":"app.py","text":"async def save_holding(request: Request):"},{"line":370,"path":"app.py","text":"quantity = max(0.0, float(x.get(\"quantity\",0)))"},{"line":373,"path":"app.py","text":"c.execute(\"INSERT INTO holdings(coin_id,quantity,avg_buy) VALUES(?,?,?) ON CONFLICT(coin_id) DO UPDATE SET quantity=excluded.quantity,avg_buy=excluded.avg_buy\",(coin,quantity,avg_buy)); c.commit()"},{"line":4,"path":"compose.yaml","text":"container_name: crypto-portfolio"},{"line":29,"path":"compose.yaml","text":"container_name: crypto-portfolio-worker"},{"line":7,"path":"data/lightweight-charts.standalone.production.js","text":"!function(){\"use strict\";const t={title:\"\",visible:!0,hitTestTolerance:3,lastValueVisible:!0,priceLineVisible:!0,priceLineSource:0,priceLineWidth:1,priceLineColor:\"\",priceLineStyle:2,baseLineVisible:!0,baseLineWidth:1,baseLineColor:\"#B2B5BE\",baseLineStyle:0,priceFormat:{type:\"price\",precision:2,minMove:.01}};var i,n;function s(t,i){const n=function(t,i){switch(t){case 0:default:return[];case 1:return[i,i];case 2:return[2*i,2*i];case 3:return[6*i,6*i];case 4:return[i,4*i]}}(i,t.lineWidth);return t.setLineDash(n),n}function e(t,i,n,s){t.beginPath();const e=t.lineWidth%2?.5:0;t.moveTo(n,i+e),t.lineTo(s,i+e),t.stroke()}function r(t,i){if(!t)throw new Error(\"Assertion failed\"+(i?\": \"+i:\"\"))}function h(t){if(void 0===t)throw new Error(\"Value is undefined\");return t}function a(t){if(null===t)throw new Error(\"Value is null\");return t}function l(t){return a(h(t))}!function(t){t[t.Simple=0]=\"Simple\",t[t.WithSteps=1]=\"WithSteps\",t[t.Curved=2]=\"Curved\"}(i||(i={})),function(t){t[t.Solid=0]=\"Solid\","},{"line":3,"path":"templates/index.html","text":"
Стоимость портфеля
Вложено
Прибыль / убыток0%
Обновление рынкакаждые 5 минут
"},{"line":9,"path":"templates/index.html","text":""},{"line":109,"text":""},{"line":217,"text":""},{"line":231,"text":""},{"line":232,"text":""}],"sha256":"3752846b9a2d47a2c82377d222e5f8603f78101eda11422bc8634567e26209d2"},"worker":{"assignments":[{"end_line":8,"line":8,"literal":null,"names":["MARKET"],"source":"MARKET = DATA / \"market.json\""},{"end_line":11,"line":11,"literal":{"avalanche-2":"AVAX","chainlink":"LINK","hedera-hashgraph":"HBAR","polygon-ecosystem-token":"POL"},"names":["COINS"],"source":"COINS = {\"hedera-hashgraph\": \"HBAR\", \"polygon-ecosystem-token\": \"POL\", \"avalanche-2\": \"AVAX\", \"chainlink\": \"LINK\"}"},{"end_line":111,"line":111,"literal":null,"names":["quantity"],"source":"quantity = float(r[\"quantity\"])"},{"end_line":112,"line":112,"literal":null,"names":["avg"],"source":"avg = float(r[\"avg_buy\"])"},{"end_line":114,"line":114,"literal":null,"names":["invested"],"source":"invested = quantity * avg"},{"end_line":123,"line":123,"literal":null,"names":["coin"],"source":"coin = market[\"coins\"].get(a[\"coin_id\"])"},{"end_line":128,"line":128,"literal":null,"names":["price"],"source":"price = float(coin[\"price\"])"},{"end_line":179,"line":179,"literal":null,"names":["market"],"source":"market = fetch_market()"}],"relevant_functions":[{"end_line":23,"line":14,"name":"connect","source":"def connect():\n c = sqlite3.connect(DB, timeout=15)\n c.row_factory = sqlite3.Row\n c.execute(\"PRAGMA journal_mode=WAL\")\n c.execute(\"CREATE TABLE IF NOT EXISTS holdings(coin_id TEXT PRIMARY KEY, quantity REAL NOT NULL DEFAULT 0, avg_buy REAL NOT NULL DEFAULT 0)\")\n c.execute(\"CREATE TABLE IF NOT EXISTS alerts(id INTEGER PRIMARY KEY AUTOINCREMENT, coin_id TEXT NOT NULL, kind TEXT NOT NULL, threshold REAL NOT NULL, window_seconds INTEGER NOT NULL DEFAULT 0, email TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, latched INTEGER NOT NULL DEFAULT 0, last_triggered INTEGER)\")\n c.execute(\"CREATE TABLE IF NOT EXISTS market_history(ts INTEGER NOT NULL, coin_id TEXT NOT NULL, price REAL NOT NULL, market_cap REAL NOT NULL, volume REAL NOT NULL DEFAULT 0, PRIMARY KEY(ts,coin_id))\")\n c.execute(\"CREATE INDEX IF NOT EXISTS idx_market_history_coin_ts ON market_history(coin_id,ts)\")\n c.commit()\n return c"},{"end_line":75,"line":41,"name":"fetch_market","source":"def fetch_market():\n params = {\"vs_currency\":\"usd\",\"ids\":\",\".join(COINS),\"order\":\"market_cap_desc\",\"per_page\":50,\"page\":1,\"sparkline\":\"true\",\"price_change_percentage\":\"1h,24h,7d\"}\n with httpx.Client(timeout=25, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as client:\n r = client.get(\"https://api.coingecko.com/api/v3/coins/markets\", params=params)\n r.raise_for_status()\n rows = r.json()\n now = int(time.time())\n usd_rub = 0.0\n try:\n with open(MARKET, \"r\", encoding=\"utf-8\") as previous_file:\n previous_rate = float((json.load(previous_file) or {}).get(\"usd_rub\") or 0)\n if 40 <= previous_rate <= 200:\n usd_rub = previous_rate\n except Exception:\n pass\n try:\n with httpx.Client(timeout=15, headers={\"User-Agent\":\"Gram-Crypto-Portfolio/1.0\"}) as fx_client:\n fx_data = fx_client.get(\"https://api.coingecko.com/api/v3/simple/price\", params={\"ids\":\"bitcoin\",\"vs_currencies\":\"usd,rub\"}).json()[\"bitcoin\"]\n candidate = float(fx_data[\"rub\"]) / float(fx_data[\"usd\"])\n if 40 <= candidate <= 200:\n usd_rub = candidate\n except Exception:\n pass\n if not 40 <= usd_rub <= 200:\n usd_rub = 77.025093\n result = {\"updated_at\":now,\"currency\":\"usd\",\"usd_rub\":usd_rub,\"coins\":{}}\n\n for x in rows:\n cid = str(x.get(\"id\",\"\"))\n if cid not in COINS:\n continue\n result[\"coins\"][cid] = {\"symbol\":COINS[cid],\"name\":x.get(\"name\"),\"price\":float(x.get(\"current_price\") or 0),\"market_cap\":float(x.get(\"market_cap\") or 0),\"rank\":x.get(\"market_cap_rank\"),\"volume\":float(x.get(\"total_volume\") or 0),\"high_24h\":x.get(\"high_24h\"),\"low_24h\":x.get(\"low_24h\"),\"change_1h\":x.get(\"price_change_percentage_1h_in_currency\"),\"change_24h\":x.get(\"price_change_percentage_24h_in_currency\"),\"change_7d\":x.get(\"price_change_percentage_7d_in_currency\"),\"sparkline_7d\":((x.get(\"sparkline_in_7d\") or {}).get(\"price\") or []),\"last_updated\":x.get(\"last_updated\")}\n if len(result[\"coins\"]) != len(COINS):\n raise RuntimeError(\"incomplete market response\")\n return result"},{"end_line":79,"line":77,"name":"reference_price","source":"def reference_price(c, coin_id, target_ts):\n r = c.execute(\"SELECT price FROM market_history WHERE coin_id=? AND ts<=? ORDER BY ts DESC LIMIT 1\",(coin_id,target_ts)).fetchone()\n return float(r[\"price\"]) if r else None"},{"end_line":117,"line":107,"name":"portfolio_text","source":"def portfolio_text(c, coin_id, price):\n r = c.execute(\"SELECT quantity,avg_buy FROM holdings WHERE coin_id=?\",(coin_id,)).fetchone()\n if not r:\n return \"\"\n quantity = float(r[\"quantity\"])\n avg = float(r[\"avg_buy\"])\n value = quantity * price\n invested = quantity * avg\n pnl = value - invested\n pct = pnl / invested * 100 if invested else 0\n return \"\\nКоличество монет: {:.8f}\\nТекущая стоимость позиции: ${:,.2f}\\nПрибыль или убыток: ${:,.2f} ({:+.2f}%)\\n\".format(quantity,value,pnl,pct)"},{"end_line":175,"line":119,"name":"evaluate","source":"def evaluate(c, market):\n now = int(time.time())\n triggered = 0\n for a in c.execute(\"SELECT * FROM alerts WHERE enabled=1 ORDER BY id\").fetchall():\n coin = market[\"coins\"].get(a[\"coin_id\"])\n if not coin:\n continue\n kind = a[\"kind\"]\n threshold = float(a[\"threshold\"])\n price = float(coin[\"price\"])\n cap = float(coin[\"market_cap\"])\n latched = int(a[\"latched\"])\n fired = False\n reset = False\n description = \"\"\n if kind == \"price_above\":\n fired = price >= threshold\n reset = price < threshold * 0.99\n description = \"Цена {} достигла ${:,.8f}, порог выше или равно ${:,.8f}.\".format(coin[\"symbol\"],price,threshold)\n elif kind == \"price_below\":\n fired = price <= threshold\n reset = price > threshold * 1.01\n description = \"Цена {} снизилась до ${:,.8f}, порог ниже или равно ${:,.8f}.\".format(coin[\"symbol\"],price,threshold)\n elif kind == \"cap_above\":\n fired = cap >= threshold\n reset = cap < threshold * 0.99\n description = \"Капитализация {} достигла ${:,.0f}, порог выше или равно ${:,.0f}.\".format(coin[\"symbol\"],cap,threshold)\n elif kind == \"cap_below\":\n fired = cap <= threshold\n reset = cap > threshold * 1.01\n description = \"Капитализация {} снизилась до ${:,.0f}, порог ниже или равно ${:,.0f}.\".format(coin[\"symbol\"],cap,threshold)\n elif kind in {\"pump\",\"dump\"}:\n old = reference_price(c,a[\"coin_id\"],now-int(a[\"window_seconds\"]))\n if old and old > 0:\n change = (price / old - 1) * 100\n if kind == \"pump\":\n fired = change >= threshold\n reset = change < threshold * 0.5\n description = \"Резкий памп {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\".format(coin[\"symbol\"],change,int(a[\"window_seconds\"])//60,old,price)\n else:\n fired = change <= -threshold\n reset = change > -threshold * 0.5\n description = \"Резкое падение {}: {:+.2f}% за {} минут. Цена была ${:,.8f}, сейчас ${:,.8f}.\".format(coin[\"symbol\"],change,int(a[\"window_seconds\"])//60,old,price)\n if reset and latched:\n c.execute(\"UPDATE alerts SET latched=0 WHERE id=?\","},{"end_line":190,"line":177,"name":"cycle","source":"def cycle():\n DATA.mkdir(parents=True, exist_ok=True)\n market = fetch_market()\n now = int(market[\"updated_at\"])\n with connect() as c:\n for cid, x in market[\"coins\"].items():\n c.execute(\"INSERT OR REPLACE INTO market_history(ts,coin_id,price,market_cap,volume) VALUES(?,?,?,?,?)\",(now,cid,x[\"price\"],x[\"market_cap\"],x[\"volume\"]))\n c.execute(\"DELETE FROM market_history WHERE ts