CHAT_OUTPUT_BEGIN COMMAND_ID=GRAM1-USDC-V42-READ-ONLY-PLAN-20260801T145300Z STATUS=OK RC=0 HOST=pve01 MODE=verify COMPONENT=gram1-usdc-v42-plan REFERENCE_REGISTER_CHECK=OK REFERENCE_SHA256=5763f2f8edc75fcf6f3951f8c95896d1cc112ceccb6cf5b64a383f540a4deb8e ERROR_REGISTER_CHECK=OK ERROR_REGISTER_SHA256=24934a2c7fa5c26f6e828583c4aefac7f143b40c0171736ed7571e617d55ab83 COMMAND_SHA256=4aa13a481b22ea81603d4ee7fc21459486180a7e1494918139d0491444affa0d DUPLICATE_FAILED_COMMAND_BLOCKED=false EXECUTION_STARTED=true CHANGE_DECLARED=false RESULT_CONTRACT_VALID=true RESULT_CONTRACT_STATUS=NOT_APPLICABLE RESULT_CONTRACT_ERROR=NONE COMMAND_RC=0 CHANGES_MADE=false ROLLBACK_STARTED=false ROLLBACK_RESTORED=null MUTATION_OUTCOME=NO_MUTATION SANITIZED=yes SECRETS_INCLUDED=no PRIVATE_ADDRESSES_INCLUDED=no RAW_EVIDENCE_SHA256=c16acd7ab954591945c4217fb1164a9d843e977e059f5813af0a186f0e2eef0e SANITIZED_OUTPUT_SHA256=42e8083f48caed53cc9b54fb6257e82b09431702c60c32afbe615c365cfff23b OUTPUT_BEGIN 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} {"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