2026-01-12 22:36:10 +08:00
"""
Portfolio API routes (local-only).
Manages manual positions (user's existing holdings) and AI monitoring tasks.
"""
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
import json
2026-01-13 00:21:33 +08:00
import threading
2026-04-09 14:30:51 +07:00
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from datetime import date , datetime , timezone
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
from flask import Blueprint , g , jsonify , request
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
2026-01-12 22:36:10 +08:00
from app.services.kline import KlineService
2026-04-09 14:30:51 +07:00
from app.services.symbol_name import resolve_symbol_name
from app.utils.auth import login_required
2026-01-12 22:36:10 +08:00
from app.utils.cache import CacheManager
from app.utils.db import get_db_connection
2026-04-09 14:30:51 +07:00
from app.utils.logger import get_logger
2026-01-12 22:36:10 +08:00
logger = get_logger ( __name__ )
2026-04-09 14:30:51 +07:00
portfolio_bp = Blueprint ( "portfolio" , __name__ )
2026-01-12 22:36:10 +08:00
kline_service = KlineService ()
cache = CacheManager ()
# Thread pool for parallel price fetching
2026-01-14 05:29:55 +08:00
# Lower concurrency to avoid triggering API limits (especially for forex/US stocks)
2026-01-13 00:21:33 +08:00
executor = ThreadPoolExecutor ( max_workers = 3 )
2026-01-14 05:29:55 +08:00
# Request interval (seconds) to avoid too frequent requests
2026-01-13 00:21:33 +08:00
REQUEST_INTERVAL = 0.3
2026-01-14 05:29:55 +08:00
# Rate limiting related
2026-01-13 00:21:33 +08:00
_request_lock = threading . Lock ()
_last_request_time = {} # {market: timestamp}
2026-01-12 22:36:10 +08:00
def _now_ts () -> int :
return int ( time . time ())
2026-03-25 13:27:48 +08:00
def _serialize_monitor_ts ( value ):
"""
2026-04-06 16:47:36 +07:00
JSON serialized monitoring time field. PostgreSQL TIMESTAMP is interpreted in UTC when there is no tz (consistent with Docker's default),
Output the ISO with Z to prevent the front end from treating the non-time zone string as local time and deviating by 8 hours.
2026-03-25 13:27:48 +08:00
"""
if value is None :
return None
if isinstance ( value , datetime ):
if value . tzinfo is None :
dt = value . replace ( tzinfo = timezone . utc )
else :
dt = value . astimezone ( timezone . utc )
s = dt . isoformat ()
if s . endswith ( "+00:00" ):
return s [: - 6 ] + "Z"
return s
if isinstance ( value , date ):
return value . isoformat ()
return value
2026-01-12 22:36:10 +08:00
def _normalize_symbol ( symbol : str ) -> str :
2026-04-09 14:30:51 +07:00
return ( symbol or "" ) . strip () . upper ()
2026-01-12 22:36:10 +08:00
def _safe_json_loads ( value , default = None ):
"""Safely parse JSON string."""
if default is None :
default = {}
if isinstance ( value , dict ):
return value
if isinstance ( value , list ):
return value
if isinstance ( value , str ) and value . strip ():
try :
return json . loads ( value )
except Exception :
return default
return default
2026-01-13 00:21:33 +08:00
def _get_single_price ( market : str , symbol : str , force_refresh : bool = False ) -> dict :
"""
Get price data for a single symbol.
2026-04-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
Priority is given to using the real-time quotation API (ticker), and downgrading to minute/daily K-line data.
This allows for more real-time prices during the trading session, rather than just showing daily closing prices.
2026-04-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
Built-in rate limit: requests for the same market must be at least REQUEST_INTERVAL seconds apart,
Avoid triggering API limits (like yfinance, Tiingo, Finnhub, etc.).
2026-04-09 14:30:51 +07:00
2026-01-13 00:21:33 +08:00
Args:
2026-04-06 16:47:36 +07:00
force_refresh: whether to force refresh (skip cache)
2026-01-13 00:21:33 +08:00
"""
2026-01-12 22:36:10 +08:00
try :
2026-04-06 16:47:36 +07:00
# Rate Limit: Interval between requests for the same market
2026-01-13 00:21:33 +08:00
with _request_lock :
now = time . time ()
last_time = _last_request_time . get ( market , 0 )
wait_time = REQUEST_INTERVAL - ( now - last_time )
if wait_time > 0 :
time . sleep ( wait_time )
_last_request_time [ market ] = time . time ()
2026-04-09 14:30:51 +07:00
2026-04-06 16:47:36 +07:00
# Get real-time prices using the new get_realtime_price method
2026-01-13 00:21:33 +08:00
price_data = kline_service . get_realtime_price ( market , symbol , force_refresh = force_refresh )
2026-04-09 14:30:51 +07:00
2026-01-13 00:21:33 +08:00
return {
2026-04-09 14:30:51 +07:00
"market" : market ,
"symbol" : symbol ,
"price" : price_data . get ( "price" , 0 ),
"change" : price_data . get ( "change" , 0 ),
"changePercent" : price_data . get ( "changePercent" , 0 ),
"source" : price_data . get ( "source" , "unknown" ), # Record data sources for easy debugging
2026-01-13 00:21:33 +08:00
}
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "Failed to fetch price { market } : { symbol } - { str ( e ) } " )
2026-04-09 14:30:51 +07:00
return { "market" : market , "symbol" : symbol , "price" : 0 , "change" : 0 , "changePercent" : 0 , "source" : "error" }
2026-01-12 22:36:10 +08:00
# ==================== Position CRUD ====================
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/positions" , methods = [ "GET" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_positions ():
2026-01-14 05:29:55 +08:00
"""Get all manual positions with current prices for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
# Check if force refresh (skip cache)
2026-04-09 14:30:51 +07:00
force_refresh = request . args . get ( "refresh" , "" ) . lower () in ( "1" , "true" , "yes" )
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at
FROM qd_manual_positions
WHERE user_id = ?
ORDER BY id DESC
""" ,
2026-04-09 14:30:51 +07:00
( user_id ,),
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
positions = []
price_futures = {}
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Prepare positions and submit price fetch tasks
for row in rows :
pos = {
2026-04-09 14:30:51 +07:00
"id" : row . get ( "id" ),
"market" : row . get ( "market" ),
"symbol" : row . get ( "symbol" ),
"name" : row . get ( "name" ) or row . get ( "symbol" ),
"side" : row . get ( "side" ) or "long" ,
"quantity" : float ( row . get ( "quantity" ) or 0 ),
"entry_price" : float ( row . get ( "entry_price" ) or 0 ),
"entry_time" : row . get ( "entry_time" ),
"notes" : row . get ( "notes" ) or "" ,
"tags" : _safe_json_loads ( row . get ( "tags" ), []),
"group_name" : row . get ( "group_name" ) or "" ,
"created_at" : row . get ( "created_at" ),
"updated_at" : row . get ( "updated_at" ),
2026-01-12 22:36:10 +08:00
# Will be filled later
2026-04-09 14:30:51 +07:00
"current_price" : 0 ,
"price_change" : 0 ,
"price_change_percent" : 0 ,
"market_value" : 0 ,
"cost_value" : 0 ,
"pnl" : 0 ,
"pnl_percent" : 0 ,
2026-01-12 22:36:10 +08:00
}
positions . append ( pos )
2026-04-09 14:30:51 +07:00
2026-01-13 00:21:33 +08:00
# Submit price fetch task (with force_refresh support)
2026-04-09 14:30:51 +07:00
market = row . get ( "market" )
symbol = row . get ( "symbol" )
2026-01-12 22:36:10 +08:00
if market and symbol :
key = f " { market } : { symbol } "
if key not in price_futures :
2026-01-13 00:21:33 +08:00
future = executor . submit ( _get_single_price , market , symbol , force_refresh )
2026-01-12 22:36:10 +08:00
price_futures [ key ] = future
# Collect price results
price_map = {}
for key , future in price_futures . items ():
try :
result = future . result ( timeout = 10 )
price_map [ key ] = result
except Exception as e :
logger . warning ( f "Price fetch failed for { key } : { e } " )
# Calculate PnL for each position
for pos in positions :
key = f " { pos [ 'market' ] } : { pos [ 'symbol' ] } "
price_data = price_map . get ( key , {})
2026-04-09 14:30:51 +07:00
current_price = float ( price_data . get ( "price" ) or 0 )
entry_price = pos [ "entry_price" ]
quantity = pos [ "quantity" ]
side = pos [ "side" ]
pos [ "current_price" ] = current_price
pos [ "price_change" ] = price_data . get ( "change" , 0 )
pos [ "price_change_percent" ] = price_data . get ( "changePercent" , 0 )
2026-01-12 22:36:10 +08:00
# Calculate values
2026-04-09 14:30:51 +07:00
pos [ "market_value" ] = current_price * quantity
pos [ "cost_value" ] = entry_price * quantity
2026-01-12 22:36:10 +08:00
# Calculate PnL based on side
2026-04-09 14:30:51 +07:00
if side == "long" :
pos [ "pnl" ] = ( current_price - entry_price ) * quantity
2026-01-12 22:36:10 +08:00
else : # short
2026-04-09 14:30:51 +07:00
pos [ "pnl" ] = ( entry_price - current_price ) * quantity
if pos [ "cost_value" ] > 0 :
pos [ "pnl_percent" ] = round ( pos [ "pnl" ] / pos [ "cost_value" ] * 100 , 2 )
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : positions })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "get_positions failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : []}), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/positions" , methods = [ "POST" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def add_position ():
2026-01-14 05:29:55 +08:00
"""Add a new manual position for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
2026-04-09 14:30:51 +07:00
market = ( data . get ( "market" ) or "" ) . strip ()
symbol = _normalize_symbol ( data . get ( "symbol" ))
name_in = ( data . get ( "name" ) or "" ) . strip ()
side = ( data . get ( "side" ) or "long" ) . strip () . lower ()
quantity = float ( data . get ( "quantity" ) or 0 )
entry_price = float ( data . get ( "entry_price" ) or 0 )
entry_time = data . get ( "entry_time" ) or _now_ts ()
notes = ( data . get ( "notes" ) or "" ) . strip ()
tags = data . get ( "tags" ) or []
group_name = ( data . get ( "group_name" ) or "" ) . strip ()
2026-01-12 22:36:10 +08:00
if not market or not symbol :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "Missing market or symbol" , "data" : None }), 400
2026-01-12 22:36:10 +08:00
if quantity <= 0 :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "Quantity must be positive" , "data" : None }), 400
2026-01-12 22:36:10 +08:00
if entry_price <= 0 :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "Entry price must be positive" , "data" : None }), 400
if side not in ( "long" , "short" ):
side = "long"
2026-01-12 22:36:10 +08:00
# Resolve display name
resolved = resolve_symbol_name ( market , symbol ) or seed_get_symbol_name ( market , symbol )
name = name_in or resolved or symbol
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
tags_json = json . dumps ( tags if isinstance ( tags , list ) else [], ensure_ascii = False )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-03-23 23:01:04 +08:00
# Delete any existing positions for this symbol (regardless of side),
# ensuring only one position per symbol per user per group.
cur . execute (
"DELETE FROM qd_manual_positions WHERE user_id = ? AND market = ? AND symbol = ? AND group_name = ?" ,
2026-04-09 14:30:51 +07:00
( user_id , market , symbol , group_name ),
2026-03-23 23:01:04 +08:00
)
2026-01-12 22:36:10 +08:00
cur . execute (
"""
2026-04-09 14:30:51 +07:00
INSERT INTO qd_manual_positions
2026-01-12 22:36:10 +08:00
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-04-09 14:30:51 +07:00
( user_id , market , symbol , name , side , quantity , entry_price , entry_time , notes , tags_json , group_name ),
2026-01-12 22:36:10 +08:00
)
position_id = cur . lastrowid
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : { "id" : position_id }})
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "add_position failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/positions/<int:position_id>" , methods = [ "PUT" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def update_position ( position_id ):
2026-01-14 05:29:55 +08:00
"""Update an existing position for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
updates = []
params = []
2026-04-09 14:30:51 +07:00
if "name" in data :
updates . append ( "name = ?" )
params . append (( data . get ( "name" ) or "" ) . strip ())
if "quantity" in data :
quantity = float ( data . get ( "quantity" ) or 0 )
2026-01-12 22:36:10 +08:00
if quantity <= 0 :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "Quantity must be positive" , "data" : None }), 400
updates . append ( "quantity = ?" )
2026-01-12 22:36:10 +08:00
params . append ( quantity )
2026-04-09 14:30:51 +07:00
if "entry_price" in data :
entry_price = float ( data . get ( "entry_price" ) or 0 )
2026-01-12 22:36:10 +08:00
if entry_price <= 0 :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "Entry price must be positive" , "data" : None }), 400
updates . append ( "entry_price = ?" )
2026-01-12 22:36:10 +08:00
params . append ( entry_price )
2026-04-09 14:30:51 +07:00
if "entry_time" in data :
updates . append ( "entry_time = ?" )
params . append ( data . get ( "entry_time" ))
if "notes" in data :
updates . append ( "notes = ?" )
params . append (( data . get ( "notes" ) or "" ) . strip ())
if "tags" in data :
tags = data . get ( "tags" ) or []
updates . append ( "tags = ?" )
2026-01-12 22:36:10 +08:00
params . append ( json . dumps ( tags if isinstance ( tags , list ) else [], ensure_ascii = False ))
2026-04-09 14:30:51 +07:00
if "group_name" in data :
updates . append ( "group_name = ?" )
params . append (( data . get ( "group_name" ) or "" ) . strip ())
2026-01-12 22:36:10 +08:00
if not updates :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "No fields to update" , "data" : None }), 400
updates . append ( "updated_at = NOW()" )
2026-01-12 22:36:10 +08:00
params . append ( position_id )
2026-01-14 05:29:55 +08:00
params . append ( user_id )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-09 14:30:51 +07:00
cur . execute ( f "UPDATE qd_manual_positions SET { ', ' . join ( updates ) } WHERE id = ? AND user_id = ?" , params )
2026-01-12 22:36:10 +08:00
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : None })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "update_position failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/positions/<int:position_id>" , methods = [ "DELETE" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def delete_position ( position_id ):
2026-01-14 05:29:55 +08:00
"""Delete a position for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-09 14:30:51 +07:00
cur . execute ( "DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?" , ( position_id , user_id ))
2026-01-12 22:36:10 +08:00
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : None })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "delete_position failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/summary" , methods = [ "GET" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_portfolio_summary ():
2026-01-14 05:29:55 +08:00
"""Get portfolio summary with total value, PnL, and market distribution for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
# Check if force refresh
2026-04-09 14:30:51 +07:00
force_refresh = request . args . get ( "refresh" , "" ) . lower () in ( "1" , "true" , "yes" )
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT id, market, symbol, side, quantity, entry_price
FROM qd_manual_positions
WHERE user_id = ?
""" ,
2026-04-09 14:30:51 +07:00
( user_id ,),
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
if not rows :
2026-04-09 14:30:51 +07:00
return jsonify (
{
"code" : 1 ,
"msg" : "success" ,
"data" : {
"total_cost" : 0 ,
"total_market_value" : 0 ,
"total_pnl" : 0 ,
"total_pnl_percent" : 0 ,
"position_count" : 0 ,
"market_distribution" : [],
},
2026-01-12 22:36:10 +08:00
}
2026-04-09 14:30:51 +07:00
)
2026-01-12 22:36:10 +08:00
2026-01-13 00:21:33 +08:00
# Fetch prices in parallel (with force_refresh support)
2026-01-12 22:36:10 +08:00
price_futures = {}
for row in rows :
2026-04-09 14:30:51 +07:00
market = row . get ( "market" )
symbol = row . get ( "symbol" )
2026-01-12 22:36:10 +08:00
key = f " { market } : { symbol } "
if key not in price_futures :
2026-01-13 00:21:33 +08:00
future = executor . submit ( _get_single_price , market , symbol , force_refresh )
2026-01-12 22:36:10 +08:00
price_futures [ key ] = future
price_map = {}
for key , future in price_futures . items ():
try :
result = future . result ( timeout = 10 )
price_map [ key ] = result
except Exception :
pass
# Calculate totals
total_cost = 0
total_market_value = 0
total_pnl = 0
market_values = {} # {market: market_value}
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
for row in rows :
2026-04-09 14:30:51 +07:00
market = row . get ( "market" )
symbol = row . get ( "symbol" )
side = row . get ( "side" ) or "long"
quantity = float ( row . get ( "quantity" ) or 0 )
entry_price = float ( row . get ( "entry_price" ) or 0 )
2026-01-12 22:36:10 +08:00
key = f " { market } : { symbol } "
price_data = price_map . get ( key , {})
2026-04-09 14:30:51 +07:00
current_price = float ( price_data . get ( "price" ) or 0 )
2026-01-12 22:36:10 +08:00
cost = entry_price * quantity
market_val = current_price * quantity
2026-04-09 14:30:51 +07:00
if side == "long" :
2026-01-12 22:36:10 +08:00
pnl = ( current_price - entry_price ) * quantity
else :
pnl = ( entry_price - current_price ) * quantity
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
total_cost += cost
total_market_value += market_val
total_pnl += pnl
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Market distribution
if market not in market_values :
market_values [ market ] = 0
market_values [ market ] += market_val
total_pnl_percent = round ( total_pnl / total_cost * 100 , 2 ) if total_cost > 0 else 0
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Build market distribution
market_distribution = []
for market , value in market_values . items ():
percent = round ( value / total_market_value * 100 , 2 ) if total_market_value > 0 else 0
2026-04-09 14:30:51 +07:00
market_distribution . append ({ "market" : market , "value" : round ( value , 2 ), "percent" : percent })
market_distribution . sort ( key = lambda x : x [ "value" ], reverse = True )
return jsonify (
{
"code" : 1 ,
"msg" : "success" ,
"data" : {
"total_cost" : round ( total_cost , 2 ),
"total_market_value" : round ( total_market_value , 2 ),
"total_pnl" : round ( total_pnl , 2 ),
"total_pnl_percent" : total_pnl_percent ,
"position_count" : len ( rows ),
"market_distribution" : market_distribution ,
},
2026-01-12 22:36:10 +08:00
}
2026-04-09 14:30:51 +07:00
)
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "get_portfolio_summary failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
# ==================== Monitor CRUD ====================
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/monitors" , methods = [ "GET" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_monitors ():
2026-01-14 05:29:55 +08:00
"""Get all position monitors for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
2026-04-09 14:30:51 +07:00
SELECT id, name, position_ids, monitor_type, config, notification_config,
2026-01-12 22:36:10 +08:00
is_active, last_run_at, next_run_at, last_result, run_count, created_at, updated_at
FROM qd_position_monitors
WHERE user_id = ?
ORDER BY id DESC
""" ,
2026-04-09 14:30:51 +07:00
( user_id ,),
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
monitors = []
for row in rows :
2026-04-09 14:30:51 +07:00
monitors . append (
{
"id" : row . get ( "id" ),
"name" : row . get ( "name" ) or "" ,
"position_ids" : _safe_json_loads ( row . get ( "position_ids" ), []),
"monitor_type" : row . get ( "monitor_type" ) or "ai" ,
"config" : _safe_json_loads ( row . get ( "config" ), {}),
"notification_config" : _safe_json_loads ( row . get ( "notification_config" ), {}),
"is_active" : bool ( row . get ( "is_active" )),
"last_run_at" : _serialize_monitor_ts ( row . get ( "last_run_at" )),
"next_run_at" : _serialize_monitor_ts ( row . get ( "next_run_at" )),
"last_result" : _safe_json_loads ( row . get ( "last_result" ), {}),
"run_count" : row . get ( "run_count" ) or 0 ,
"created_at" : _serialize_monitor_ts ( row . get ( "created_at" )),
"updated_at" : _serialize_monitor_ts ( row . get ( "updated_at" )),
}
)
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : monitors })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "get_monitors failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : []}), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/monitors" , methods = [ "POST" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def add_monitor ():
2026-01-14 05:29:55 +08:00
"""Add a new position monitor for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
2026-04-09 14:30:51 +07:00
name = ( data . get ( "name" ) or "" ) . strip ()
position_ids = data . get ( "position_ids" ) or []
monitor_type = ( data . get ( "monitor_type" ) or "ai" ) . strip ()
config = data . get ( "config" ) or {}
notification_config = data . get ( "notification_config" ) or {}
is_active = bool ( data . get ( "is_active" , True ))
2026-01-12 22:36:10 +08:00
if not name :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "Monitor name is required" , "data" : None }), 400
if monitor_type not in ( "ai" , "price_alert" , "pnl_alert" ):
monitor_type = "ai"
2026-03-23 23:01:04 +08:00
# Calculate next_run_at based on interval (frontend sends run_interval_minutes)
2026-04-09 14:30:51 +07:00
interval_minutes = int ( config . get ( "run_interval_minutes" ) or config . get ( "interval_minutes" ) or 60 )
2026-01-12 22:36:10 +08:00
position_ids_json = json . dumps ( position_ids if isinstance ( position_ids , list ) else [], ensure_ascii = False )
config_json = json . dumps ( config if isinstance ( config , dict ) else {}, ensure_ascii = False )
2026-04-09 14:30:51 +07:00
notification_config_json = json . dumps (
notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False
)
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
2026-04-09 14:30:51 +07:00
INSERT INTO qd_position_monitors
2026-01-12 22:36:10 +08:00
(user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL ' %s minutes', NOW(), NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-04-09 14:30:51 +07:00
(
user_id ,
name ,
position_ids_json ,
monitor_type ,
config_json ,
notification_config_json ,
1 if is_active else 0 ,
interval_minutes ,
),
2026-01-12 22:36:10 +08:00
)
monitor_id = cur . lastrowid
db . commit ()
cur . close ()
2026-04-06 16:47:36 +07:00
# Run one round in the background immediately after creation: send a notification immediately, and write next_run_at based on the completion time (execute again after the interval)
2026-03-24 19:10:18 +08:00
if is_active and monitor_id :
try :
from app.services.portfolio_monitor import run_single_monitor as _run_single_monitor
def _initial_run ():
try :
_run_single_monitor ( int ( monitor_id ), user_id = int ( user_id ))
except Exception as ex :
logger . error ( f "Initial portfolio monitor run failed # { monitor_id } : { ex } " )
threading . Thread (
target = _initial_run ,
daemon = True ,
name = f "monitor-init- { monitor_id } " ,
) . start ()
except Exception as ex :
logger . error ( f "Failed to schedule initial monitor run # { monitor_id } : { ex } " )
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : { "id" : monitor_id }})
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "add_monitor failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/monitors/<int:monitor_id>" , methods = [ "PUT" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def update_monitor ( monitor_id ):
2026-01-14 05:29:55 +08:00
"""Update an existing monitor for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
updates = []
params = []
2026-04-09 14:30:51 +07:00
if "name" in data :
updates . append ( "name = ?" )
params . append (( data . get ( "name" ) or "" ) . strip ())
if "position_ids" in data :
position_ids = data . get ( "position_ids" ) or []
updates . append ( "position_ids = ?" )
2026-01-12 22:36:10 +08:00
params . append ( json . dumps ( position_ids if isinstance ( position_ids , list ) else [], ensure_ascii = False ))
2026-04-09 14:30:51 +07:00
if "monitor_type" in data :
updates . append ( "monitor_type = ?" )
params . append (( data . get ( "monitor_type" ) or "ai" ) . strip ())
2026-01-14 05:29:55 +08:00
next_run_interval = None # Will store interval for special handling
2026-04-09 14:30:51 +07:00
if "config" in data :
config = data . get ( "config" ) or {}
updates . append ( "config = ?" )
2026-01-12 22:36:10 +08:00
params . append ( json . dumps ( config if isinstance ( config , dict ) else {}, ensure_ascii = False ))
2026-04-09 14:30:51 +07:00
2026-03-23 23:01:04 +08:00
# Recalculate next_run_at if interval changed
2026-04-09 14:30:51 +07:00
next_run_interval = int ( config . get ( "run_interval_minutes" ) or config . get ( "interval_minutes" ) or 60 )
if "notification_config" in data :
notification_config = data . get ( "notification_config" ) or {}
updates . append ( "notification_config = ?" )
params . append (
json . dumps ( notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False )
)
if "is_active" in data :
updates . append ( "is_active = ?" )
params . append ( 1 if data . get ( "is_active" ) else 0 )
2026-01-12 22:36:10 +08:00
if not updates :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "No fields to update" , "data" : None }), 400
2026-01-14 05:29:55 +08:00
# Add next_run_at update if interval was changed
if next_run_interval is not None :
updates . append ( f "next_run_at = NOW() + INTERVAL ' { next_run_interval } minutes'" )
2026-04-09 14:30:51 +07:00
updates . append ( "updated_at = NOW()" )
2026-01-12 22:36:10 +08:00
params . append ( monitor_id )
2026-01-14 05:29:55 +08:00
params . append ( user_id )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-09 14:30:51 +07:00
cur . execute ( f "UPDATE qd_position_monitors SET { ', ' . join ( updates ) } WHERE id = ? AND user_id = ?" , params )
2026-01-12 22:36:10 +08:00
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : None })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "update_monitor failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/monitors/<int:monitor_id>" , methods = [ "DELETE" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def delete_monitor ( monitor_id ):
2026-01-14 05:29:55 +08:00
"""Delete a monitor for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-09 14:30:51 +07:00
cur . execute ( "DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?" , ( monitor_id , user_id ))
2026-01-12 22:36:10 +08:00
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : None })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "delete_monitor failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/monitors/<int:monitor_id>/run" , methods = [ "POST" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def run_monitor_now ( monitor_id ):
2026-01-14 20:42:10 +08:00
"""Manually trigger a monitor to run immediately.
2026-04-09 14:30:51 +07:00
2026-01-14 20:42:10 +08:00
Supports two modes:
- async=true (default): Returns immediately, runs in background, notifies via notification system
- async=false: Waits for completion and returns result (may timeout for large portfolios)
"""
2026-01-12 22:36:10 +08:00
try :
from app.services.portfolio_monitor import run_single_monitor
2026-04-09 14:30:51 +07:00
2026-01-14 20:42:10 +08:00
user_id = g . user_id
2026-04-09 14:30:51 +07:00
2026-01-14 20:42:10 +08:00
# Get parameters from request body
2026-01-12 22:36:10 +08:00
data = request . get_json ( force = True , silent = True ) or {}
2026-04-09 14:30:51 +07:00
language = data . get ( "language" )
async_mode = data . get ( "async" , True ) # Default to async mode
2026-01-14 20:42:10 +08:00
# Fallback to Accept-Language header for language
2026-01-12 22:36:10 +08:00
if not language :
2026-04-09 14:30:51 +07:00
accept_lang = request . headers . get ( "Accept-Language" , "" )
if "zh" in accept_lang . lower ():
language = "zh-CN"
2026-01-12 22:36:10 +08:00
else :
2026-04-09 14:30:51 +07:00
language = "en-US"
2026-01-14 20:42:10 +08:00
if async_mode :
# Async mode: Start background thread and return immediately
import threading
2026-04-09 14:30:51 +07:00
2026-01-14 20:42:10 +08:00
def run_in_background ( mid , lang , uid ):
try :
run_single_monitor ( mid , override_language = lang , user_id = uid )
except Exception as e :
logger . error ( f "Background monitor run failed: { e } " )
2026-04-09 14:30:51 +07:00
thread = threading . Thread ( target = run_in_background , args = ( monitor_id , language , user_id ), daemon = True )
2026-01-14 20:42:10 +08:00
thread . start ()
2026-04-09 14:30:51 +07:00
return jsonify (
{
"code" : 1 ,
"msg" : "success" ,
"data" : {
"status" : "running" ,
"message" : "Monitor is running in background. Results will be sent via notification." ,
},
2026-01-14 20:42:10 +08:00
}
2026-04-09 14:30:51 +07:00
)
2026-01-14 20:42:10 +08:00
else :
# Sync mode: Wait for completion (may timeout)
result = run_single_monitor ( monitor_id , override_language = language , user_id = user_id )
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : result })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "run_monitor_now failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
# ==================== Alerts CRUD ====================
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/alerts" , methods = [ "GET" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_alerts ():
2026-01-14 05:29:55 +08:00
"""Get all position alerts for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
a.notification_config, a.is_active, a.is_triggered, a.last_triggered_at,
a.trigger_count, a.repeat_interval, a.notes, a.created_at, a.updated_at,
p.name as position_name, p.side as position_side
FROM qd_position_alerts a
LEFT JOIN qd_manual_positions p ON a.position_id = p.id
WHERE a.user_id = ?
ORDER BY a.id DESC
""" ,
2026-04-09 14:30:51 +07:00
( user_id ,),
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
alerts = []
for row in rows :
2026-04-09 14:30:51 +07:00
alerts . append (
{
"id" : row . get ( "id" ),
"position_id" : row . get ( "position_id" ),
"market" : row . get ( "market" ) or "" ,
"symbol" : row . get ( "symbol" ) or "" ,
"alert_type" : row . get ( "alert_type" ) or "price_above" ,
"threshold" : float ( row . get ( "threshold" ) or 0 ),
"notification_config" : _safe_json_loads ( row . get ( "notification_config" ), {}),
"is_active" : bool ( row . get ( "is_active" )),
"is_triggered" : bool ( row . get ( "is_triggered" )),
"last_triggered_at" : row . get ( "last_triggered_at" ),
"trigger_count" : row . get ( "trigger_count" ) or 0 ,
"repeat_interval" : row . get ( "repeat_interval" ) or 0 ,
"notes" : row . get ( "notes" ) or "" ,
"created_at" : row . get ( "created_at" ),
"updated_at" : row . get ( "updated_at" ),
"position_name" : row . get ( "position_name" ) or "" ,
"position_side" : row . get ( "position_side" ) or "long" ,
}
)
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : alerts })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "get_alerts failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : []}), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/alerts" , methods = [ "POST" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def add_alert ():
2026-01-14 05:29:55 +08:00
"""Add a new position alert for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
2026-04-09 14:30:51 +07:00
position_id = data . get ( "position_id" ) # Can be None for symbol-level alerts
market = ( data . get ( "market" ) or "" ) . strip ()
symbol = _normalize_symbol ( data . get ( "symbol" ))
alert_type = ( data . get ( "alert_type" ) or "price_above" ) . strip ()
threshold = float ( data . get ( "threshold" ) or 0 )
notification_config = data . get ( "notification_config" ) or {}
is_active = bool ( data . get ( "is_active" , True ))
repeat_interval = int ( data . get ( "repeat_interval" ) or 0 )
notes = ( data . get ( "notes" ) or "" ) . strip ()
2026-01-12 22:36:10 +08:00
# Validate alert_type
2026-04-09 14:30:51 +07:00
valid_types = ( "price_above" , "price_below" , "pnl_above" , "pnl_below" )
2026-01-12 22:36:10 +08:00
if alert_type not in valid_types :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : f "Invalid alert_type. Must be one of: { valid_types } " , "data" : None }), 400
2026-01-12 22:36:10 +08:00
# If position_id provided, get market/symbol from position
if position_id :
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?" ,
2026-04-09 14:30:51 +07:00
( position_id , user_id ),
2026-01-12 22:36:10 +08:00
)
pos = cur . fetchone ()
cur . close ()
if pos :
2026-04-09 14:30:51 +07:00
market = pos . get ( "market" ) or market
symbol = pos . get ( "symbol" ) or symbol
2026-01-12 22:36:10 +08:00
if not market or not symbol :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "Market and symbol are required" , "data" : None }), 400
if threshold <= 0 and alert_type . startswith ( "price_" ):
return jsonify ({ "code" : 0 , "msg" : "Threshold must be positive for price alerts" , "data" : None }), 400
notification_config_json = json . dumps (
notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False
)
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Check if alert already exists for this position (unique constraint)
existing_alert_id = None
if position_id :
cur . execute (
2026-04-09 14:30:51 +07:00
"SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?" , ( position_id , user_id )
2026-01-12 22:36:10 +08:00
)
existing = cur . fetchone ()
if existing :
2026-04-09 14:30:51 +07:00
existing_alert_id = existing . get ( "id" )
2026-01-12 22:36:10 +08:00
if existing_alert_id :
# Update existing alert instead of creating a new one
cur . execute (
"""
2026-04-09 14:30:51 +07:00
UPDATE qd_position_alerts
SET alert_type = ?, threshold = ?, notification_config = ?,
2026-01-14 05:29:55 +08:00
is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = NOW()
2026-01-12 22:36:10 +08:00
WHERE id = ?
""" ,
2026-04-09 14:30:51 +07:00
(
alert_type ,
threshold ,
notification_config_json ,
1 if is_active else 0 ,
repeat_interval ,
notes ,
existing_alert_id ,
),
2026-01-12 22:36:10 +08:00
)
alert_id = existing_alert_id
else :
# Create new alert
cur . execute (
"""
2026-04-09 14:30:51 +07:00
INSERT INTO qd_position_alerts
(user_id, position_id, market, symbol, alert_type, threshold, notification_config,
2026-01-12 22:36:10 +08:00
is_active, repeat_interval, notes, created_at, updated_at)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-04-09 14:30:51 +07:00
(
user_id ,
position_id ,
market ,
symbol ,
alert_type ,
threshold ,
notification_config_json ,
1 if is_active else 0 ,
repeat_interval ,
notes ,
),
2026-01-12 22:36:10 +08:00
)
alert_id = cur . lastrowid
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : { "id" : alert_id }})
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "add_alert failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/alerts/<int:alert_id>" , methods = [ "PUT" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def update_alert ( alert_id ):
2026-01-14 05:29:55 +08:00
"""Update an existing alert for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
updates = []
params = []
2026-04-09 14:30:51 +07:00
if "alert_type" in data :
updates . append ( "alert_type = ?" )
params . append (( data . get ( "alert_type" ) or "price_above" ) . strip ())
if "threshold" in data :
updates . append ( "threshold = ?" )
params . append ( float ( data . get ( "threshold" ) or 0 ))
if "notification_config" in data :
notification_config = data . get ( "notification_config" ) or {}
updates . append ( "notification_config = ?" )
params . append (
json . dumps ( notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False )
)
if "is_active" in data :
updates . append ( "is_active = ?" )
params . append ( 1 if data . get ( "is_active" ) else 0 )
2026-01-12 22:36:10 +08:00
# Reset triggered state when re-activating
2026-04-09 14:30:51 +07:00
if data . get ( "is_active" ):
updates . append ( "is_triggered = ?" )
2026-01-12 22:36:10 +08:00
params . append ( 0 )
2026-04-09 14:30:51 +07:00
if "repeat_interval" in data :
updates . append ( "repeat_interval = ?" )
params . append ( int ( data . get ( "repeat_interval" ) or 0 ))
if "notes" in data :
updates . append ( "notes = ?" )
params . append (( data . get ( "notes" ) or "" ) . strip ())
2026-01-12 22:36:10 +08:00
if not updates :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "No fields to update" , "data" : None }), 400
updates . append ( "updated_at = NOW()" )
2026-01-12 22:36:10 +08:00
params . append ( alert_id )
2026-01-14 05:29:55 +08:00
params . append ( user_id )
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-09 14:30:51 +07:00
cur . execute ( f "UPDATE qd_position_alerts SET { ', ' . join ( updates ) } WHERE id = ? AND user_id = ?" , params )
2026-01-12 22:36:10 +08:00
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : None })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "update_alert failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/alerts/<int:alert_id>" , methods = [ "DELETE" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def delete_alert ( alert_id ):
2026-01-14 05:29:55 +08:00
"""Delete an alert for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
2026-04-09 14:30:51 +07:00
cur . execute ( "DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?" , ( alert_id , user_id ))
2026-01-12 22:36:10 +08:00
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : None })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "delete_alert failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
# ==================== Groups ====================
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/groups" , methods = [ "GET" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_groups ():
2026-01-14 05:29:55 +08:00
"""Get list of all groups with position counts for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT group_name, COUNT(*) as count
FROM qd_manual_positions
WHERE user_id = ? AND group_name != ''
GROUP BY group_name
ORDER BY group_name
""" ,
2026-04-09 14:30:51 +07:00
( user_id ,),
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
2026-04-09 14:30:51 +07:00
2026-01-12 22:36:10 +08:00
# Also get count of ungrouped
cur . execute (
"SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')" ,
2026-04-09 14:30:51 +07:00
( user_id ,),
2026-01-12 22:36:10 +08:00
)
ungrouped = cur . fetchone ()
cur . close ()
groups = []
for row in rows :
2026-04-09 14:30:51 +07:00
groups . append ({ "name" : row . get ( "group_name" ), "count" : row . get ( "count" ) or 0 })
2026-01-12 22:36:10 +08:00
# Add ungrouped count
2026-04-09 14:30:51 +07:00
ungrouped_count = ( ungrouped . get ( "count" ) or 0 ) if ungrouped else 0
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : { "groups" : groups , "ungrouped_count" : ungrouped_count }})
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "get_groups failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500
2026-01-12 22:36:10 +08:00
2026-04-09 14:30:51 +07:00
@portfolio_bp.route ( "/groups/rename" , methods = [ "POST" ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def rename_group ():
2026-01-14 05:29:55 +08:00
"""Rename a group for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
2026-04-09 14:30:51 +07:00
old_name = ( data . get ( "old_name" ) or "" ) . strip ()
new_name = ( data . get ( "new_name" ) or "" ) . strip ()
2026-01-12 22:36:10 +08:00
if not old_name :
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : "old_name is required" , "data" : None }), 400
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
2026-01-14 05:29:55 +08:00
"UPDATE qd_manual_positions SET group_name = ?, updated_at = NOW() WHERE user_id = ? AND group_name = ?" ,
2026-04-09 14:30:51 +07:00
( new_name , user_id , old_name ),
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 1 , "msg" : "success" , "data" : None })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "rename_group failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
2026-04-09 14:30:51 +07:00
return jsonify ({ "code" : 0 , "msg" : str ( e ), "data" : None }), 500