diff --git a/dashboard-api/main.py b/dashboard-api/main.py index 41171f6..0f7fe42 100644 --- a/dashboard-api/main.py +++ b/dashboard-api/main.py @@ -2110,6 +2110,11 @@ async def proxy_decisions_accuracy(days: int = Query(default=60, ge=7, le=365)): """결정안 실측 성과 (score-engine /decisions/accuracy)""" return await _proxy_get(f"{SCORE_ENGINE_URL}/decisions/accuracy?days={days}") +@app.get("/api/trade-history") +async def proxy_trade_history(days: int = Query(default=30, ge=1, le=365)): + """모의매매 이력 + 손익 (score-engine /trade/history)""" + return await _proxy_get(f"{SCORE_ENGINE_URL}/trade/history?days={days}") + async def _enrich_kr_names(rows): """rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부""" diff --git a/score-engine/main.py b/score-engine/main.py index da0a74f..ff09f7d 100644 --- a/score-engine/main.py +++ b/score-engine/main.py @@ -5867,6 +5867,7 @@ async def ensure_trading_tables(): # 자동매매 안전 설정 — 보수적 기본값 TRADE_SETTINGS = { "enabled": True, # 자동매매 ON/OFF 토글 + "auto_execute": True, # 제안 즉시 자동 체결(모의). False면 텔레그램 버튼 승인 대기 "max_position_pct": 10.0, # 종목당 자본 ≤ 10% "daily_loss_limit_pct": -3.0, # 일일 손실 -3% 도달 시 매매 중단 "max_orders_per_day": 10, @@ -6190,59 +6191,101 @@ async def trade_sell_scan(): return {"checked": len(codes), "proposed": len(results), "results": results} +async def _fill_order(conn, order_id: int, auto: bool = False) -> dict: + """주문 체결(모의=DB 기록, 실제 브로커 전송 없음). auto=True면 자동매매 체결.""" + order = await conn.fetchrow( + "SELECT * FROM trading_orders WHERE id=$1 AND status='pending'", order_id) + if not order: + return {"error": "주문 없음 또는 이미 처리됨"} + if order["expires_at"] and order["expires_at"] < datetime.now(timezone.utc): + await conn.execute( + "UPDATE trading_orders SET status='expired' WHERE id=$1", order_id) + return {"error": "주문 만료"} + tag = "자동체결" if auto else "체결" + if order["side"] == "buy": + await conn.execute(""" + INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo) + VALUES ($1, $2, $3, $4, $5) + """, order["stock_code"], order["stock_name"], order["price"], + order["qty"], f"자동매매 #{order_id} (모의)") + msg = (f"✅ 매수 {tag} #{order_id} (모의)\n" + f"{order['stock_name']}({order['stock_code']}) " + f"{order['qty']}주 × {order['price']:,}원") + else: # sell + held = await conn.fetchrow(""" + SELECT id, buy_price, qty FROM user_portfolio + WHERE stock_code=$1 AND active=true LIMIT 1 + """, order["stock_code"]) + realized = 0 + if held: + realized = (order["price"] - held["buy_price"]) * order["qty"] + await conn.execute( + "UPDATE user_portfolio SET active=false WHERE id=$1", held["id"]) + await conn.execute(""" + INSERT INTO trading_daily_pnl (dt, realized_pnl, trades_count) + VALUES (CURRENT_DATE, $1, 1) + ON CONFLICT (dt) DO UPDATE SET + realized_pnl = trading_daily_pnl.realized_pnl + EXCLUDED.realized_pnl, + trades_count = trading_daily_pnl.trades_count + 1, + updated_at = NOW() + """, realized) + sign = "🟢" if realized > 0 else "🔴" + msg = (f"{sign} 매도 {tag} #{order_id} (모의)\n" + f"{order['stock_name']}({order['stock_code']}) " + f"{order['qty']}주 × {order['price']:,}원\n" + f"실현손익: {realized:+,}원") + await conn.execute(""" + UPDATE trading_orders + SET status='filled', confirmed_at=NOW(), filled_at=NOW() + WHERE id=$1 + """, order_id) + await send_telegram(msg) + return {"status": "filled", "order_id": order_id} + @app.post("/trade/confirm/{order_id}") async def trade_confirm_v2(order_id: int): # noqa: F811 (이전 정의 override) - """매수/매도 confirm 통합 처리.""" + """매수/매도 confirm 통합 처리 (수동 버튼).""" async with pg_pool.acquire() as conn: - order = await conn.fetchrow( - "SELECT * FROM trading_orders WHERE id=$1 AND status='pending'", order_id) - if not order: - return {"error": "주문 없음 또는 이미 처리됨"} - if order["expires_at"] and order["expires_at"] < datetime.now(timezone.utc): - await conn.execute( - "UPDATE trading_orders SET status='expired' WHERE id=$1", order_id) - return {"error": "주문 만료"} - if order["side"] == "buy": - await conn.execute(""" - INSERT INTO user_portfolio (stock_code, stock_name, buy_price, qty, memo) - VALUES ($1, $2, $3, $4, $5) - """, order["stock_code"], order["stock_name"], order["price"], - order["qty"], f"자동매매 #{order_id} (모의)") - msg = (f"✅ 매수 체결 #{order_id}\n" - f"{order['stock_name']}({order['stock_code']}) " - f"{order['qty']}주 × {order['price']:,}원") - else: # sell - # 보유 종목 비활성화 + 실현손익 계산 - held = await conn.fetchrow(""" - SELECT id, buy_price, qty FROM user_portfolio - WHERE stock_code=$1 AND active=true LIMIT 1 - """, order["stock_code"]) - realized = 0 - if held: - realized = (order["price"] - held["buy_price"]) * order["qty"] - await conn.execute( - "UPDATE user_portfolio SET active=false WHERE id=$1", held["id"]) - # 일일 손익 누적 - await conn.execute(""" - INSERT INTO trading_daily_pnl (dt, realized_pnl, trades_count) - VALUES (CURRENT_DATE, $1, 1) - ON CONFLICT (dt) DO UPDATE SET - realized_pnl = trading_daily_pnl.realized_pnl + EXCLUDED.realized_pnl, - trades_count = trading_daily_pnl.trades_count + 1, - updated_at = NOW() - """, realized) - sign = "🟢" if realized > 0 else "🔴" - msg = (f"{sign} 매도 체결 #{order_id}\n" - f"{order['stock_name']}({order['stock_code']}) " - f"{order['qty']}주 × {order['price']:,}원\n" - f"실현손익: {realized:+,}원") - await conn.execute(""" - UPDATE trading_orders - SET status='filled', confirmed_at=NOW(), filled_at=NOW() - WHERE id=$1 - """, order_id) - await send_telegram(msg) - return {"status": "filled", "order_id": order_id} + return await _fill_order(conn, order_id, auto=False) + +@app.get("/trade/history") +async def trade_history(days: int = Query(default=30, ge=1, le=365)): + """모의매매 이력 + 손익 — 체결내역·일별손익·보유 평가손익.""" + async with pg_pool.acquire() as conn: + fills = await conn.fetch(""" + SELECT stock_code, stock_name, side, qty, price, filled_at + FROM trading_orders + WHERE status='filled' AND filled_at >= CURRENT_DATE - ($1::int) + ORDER BY filled_at DESC + """, days) + daily = await conn.fetch(""" + SELECT dt, realized_pnl, unrealized_pnl, trades_count, halted + FROM trading_daily_pnl WHERE dt >= CURRENT_DATE - ($1::int) ORDER BY dt DESC + """, days) + holds = await conn.fetch(""" + SELECT p.stock_code, p.stock_name, p.buy_price, p.qty, + (SELECT price FROM stock_technical WHERE stock_code=p.stock_code) cur + FROM user_portfolio p WHERE active=true + """) + realized = sum((r["realized_pnl"] or 0) for r in daily) + holdings, unreal = [], 0 + for h in holds: + cur = h["cur"] or h["buy_price"] + pl = int((cur - h["buy_price"]) * h["qty"]) if h["buy_price"] else 0 + unreal += pl + holdings.append({"code": h["stock_code"], "name": h["stock_name"], + "buy_price": h["buy_price"], "qty": h["qty"], "cur_price": cur, + "pl": pl, "pl_pct": round((cur / h["buy_price"] - 1) * 100, 1) if h["buy_price"] else 0}) + return { + "realized_pnl": realized, "unrealized_pnl": unreal, "total_pnl": realized + unreal, + "fill_count": len(fills), + "holdings": holdings, + "daily_pnl": [{"dt": str(r["dt"]), "realized": r["realized_pnl"], + "unrealized": r["unrealized_pnl"], "trades": r["trades_count"], + "halted": r["halted"]} for r in daily], + "fills": [{"code": f["stock_code"], "name": f["stock_name"], "side": f["side"], + "qty": f["qty"], "price": f["price"], "at": str(f["filled_at"])} for f in fills], + } async def auto_trade_scan_job(): @@ -6298,6 +6341,13 @@ async def auto_trade_scan_job(): sig = await evaluate_buy_signal(conn, c) if sig["ok"]: await propose_buy_order(conn, c) + # 자동실행(모의): auto_execute면 오늘 제안된 pending 주문 즉시 체결 + if TRADE_SETTINGS.get("auto_execute"): + pend = await conn.fetch( + "SELECT id FROM trading_orders WHERE status='pending' " + "AND proposed_at::date=CURRENT_DATE") + for p in pend: + await _fill_order(conn, p["id"], auto=True) logger.info("auto_trade_scan.done") except Exception as e: logger.error("auto_trade_scan.err", error=str(e))