init
This commit is contained in:
@@ -726,13 +726,13 @@ class AnalyzerSqlite:
|
||||
|
||||
if len(volume)>2:
|
||||
if volume[0] > volume[1]*7:
|
||||
if avg120[0] < ichimokucloud_baseLine[0]:
|
||||
if ichimokucloud_baseLine[0] is not None and avg120[0] < ichimokucloud_baseLine[0]:
|
||||
type = "5_거래량 7배 이상_120일선_위_기준선"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if len(volume)>2:
|
||||
if volume[0] > volume[1]*7:
|
||||
if avg20[0] < ichimokucloud_baseLine[0]:
|
||||
if ichimokucloud_baseLine[0] is not None and avg20[0] < ichimokucloud_baseLine[0]:
|
||||
type = "6_거래량 7배 이상_20일선_위_기준선"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
@@ -811,8 +811,15 @@ class AnalyzerSqlite:
|
||||
stock = {"CODE": item[0], "NAME": item[1], "PRICE":[]}
|
||||
print("# :", rowid, ", CODE: ", stock['CODE'], ", NAME: ", stock['NAME'])
|
||||
|
||||
ymd = None
|
||||
cursor.execute('SELECT ymd FROM ' + stockAnalysisTableName + ' WHERE CODE=? order by ymd desc', (stock['CODE'],))
|
||||
result = cursor.fetchone()
|
||||
if result == None:
|
||||
ymd = result[0]
|
||||
|
||||
sql = 'SELECT ymd, close, diff, open, high, low, volume FROM ' + stockTableName + ' where CODE=? order by ymd'
|
||||
sql = 'SELECT ymd, close, diff, open, high, low, volume FROM ' + stockTableName + ' where CODE=? order by ymd '
|
||||
if ymd is not None:
|
||||
sql += 'limit 300'
|
||||
cursor.execute(sql, (stock['CODE'],))
|
||||
items = cursor.fetchall()
|
||||
for item in items:
|
||||
@@ -872,6 +879,886 @@ class AnalyzerSqlite:
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":import os
|
||||
import time
|
||||
import shutil
|
||||
import matplotlib.pyplot as plt
|
||||
import datetime
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from matplotlib import rc
|
||||
|
||||
rc('font', family='AppleGothic')
|
||||
plt.rcParams['axes.unicode_minus'] = False
|
||||
|
||||
import plotly.graph_objs as go
|
||||
from plotly import tools, subplots
|
||||
import plotly.io as po
|
||||
|
||||
|
||||
from stockpredictor.analysis.Common import Common
|
||||
from stockpredictor.analysis.Stochastic import Stochastic
|
||||
from stockpredictor.analysis.BolingerBand import BolingerBand
|
||||
from stockpredictor.analysis.IchimokuCloud import IchimokuCloud
|
||||
from stockpredictor.crawler.sQLite.MovingAverage import MovingAverage
|
||||
|
||||
|
||||
class AnalyzerSqlite:
|
||||
PROJECT_HOME = None
|
||||
|
||||
stochastic = None
|
||||
bolingerBand = None
|
||||
ichimokuCloud = None
|
||||
|
||||
top500 = None
|
||||
fnguide = None
|
||||
|
||||
common = None
|
||||
stockFileName = None
|
||||
analyzedFileName = None
|
||||
|
||||
moving_avg = None
|
||||
|
||||
def __init__(self, PROJECT_HOME, stockFileName):
|
||||
self.PROJECT_HOME = PROJECT_HOME
|
||||
self.stockFileName = stockFileName
|
||||
|
||||
self.common = Common()
|
||||
|
||||
self.stochastic = Stochastic()
|
||||
self.bolingerBand = BolingerBand()
|
||||
self.ichimokuCloud = IchimokuCloud()
|
||||
|
||||
self.top500 = self.getTop500(stockFileName)
|
||||
self.fnguide = self.readFnguide(stockFileName)
|
||||
|
||||
return
|
||||
|
||||
def getTop500(self, fnguideFileName):
|
||||
conn = sqlite3.connect(fnguideFileName)
|
||||
cursor = conn.cursor()
|
||||
|
||||
sql = "select DISTINCT CODE, NAME from fnguide order by total_ownership_interest desc limit 500"
|
||||
cursor.execute(sql)
|
||||
result = cursor.fetchall()
|
||||
|
||||
top500 = {}
|
||||
for idx, item in enumerate(result):
|
||||
top500[item[0]] = (idx+1, item[1])
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return top500
|
||||
|
||||
def readFnguide(self, fnguideFileName):
|
||||
conn = sqlite3.connect(fnguideFileName)
|
||||
cursor = conn.cursor()
|
||||
|
||||
today = datetime.today()
|
||||
year1 = str(today.year - 1) + ".12.01"
|
||||
year2 = str(today.year - 2) + ".12.01"
|
||||
year3 = str(today.year - 3) + ".12.01"
|
||||
|
||||
sql = "SELECT CODE, NAME, ymd, business_profits, business_profits_ratio, debt_ratio, ROA, ROE, EPS, BPS, DPS, PER, PBR FROM fnguide "
|
||||
sql += " WHERE (ymd=? or ymd=? or ymd=?) and type=''"
|
||||
sql += " order by code, ymd desc"
|
||||
cursor.execute(sql, (year1,year2,year3))
|
||||
result = cursor.fetchall()
|
||||
|
||||
fnguide = {}
|
||||
for item in result:
|
||||
if item[0] not in fnguide:
|
||||
fnguide[item[0]] = []
|
||||
|
||||
fnguide[item[0]].append(
|
||||
{'NAME': item[1],
|
||||
'ymd': item[2],
|
||||
'business_profits': item[3],
|
||||
'business_profits_ratio': item[4],
|
||||
'debt_ratio': item[5],
|
||||
'ROA': item[6],
|
||||
'ROE': item[7],
|
||||
'EPS': item[8],
|
||||
'BPS': item[9],
|
||||
'DPS': item[10],
|
||||
'PER': item[11],
|
||||
'PBR': item[12]})
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return fnguide
|
||||
|
||||
def draw(self, stock):
|
||||
|
||||
ymd = list(reversed(stock['ymd']))
|
||||
open = list(reversed(stock['open']))
|
||||
close = list(reversed(stock['close']))
|
||||
high = list(reversed(stock['high']))
|
||||
low = list(reversed(stock['low']))
|
||||
volume = list(reversed(stock['volume']))
|
||||
avg5 = list(reversed(stock['avg5']))
|
||||
avg20 = list(reversed(stock['avg20']))
|
||||
avg60 = list(reversed(stock['avg60']))
|
||||
avg120 = list(reversed(stock['avg120']))
|
||||
avg240 = list(reversed(stock['avg240']))
|
||||
stochastic_slow_k = list(reversed(stock['stochastic_slow_k']))
|
||||
stochastic_slow_d = list(reversed(stock['stochastic_slow_d']))
|
||||
bolingerband_upper = list(reversed(stock['bolingerband_upper']))
|
||||
bolingerband_lower = list(reversed(stock['bolingerband_lower']))
|
||||
ichimokucloud_changeLine = list(reversed(stock['ichimokucloud_changeLine']))
|
||||
ichimokucloud_baseLine = list(reversed(stock['ichimokucloud_baseLine']))
|
||||
|
||||
# general
|
||||
candle_stick = go.Candlestick(x=ymd, open=open, high=high, low=low, close=close, increasing_line_color='red', decreasing_line_color='blue')
|
||||
avg5 = go.Scatter(x=ymd, y=avg5, name="avg5", line_color='#000000')
|
||||
avg20 = go.Scatter(x=ymd, y=avg20, name="avg20", line_color='#f84c43')
|
||||
avg60 = go.Scatter(x=ymd, y=avg60, name="avg60", line_color='#f89543')
|
||||
avg120 = go.Scatter(x=ymd, y=avg120, name="avg120", line_color='#0ed604')
|
||||
avg240 = go.Scatter(x=ymd, y=avg240, name="avg240", line_color='#00FF49')
|
||||
bolinger_upper = go.Scatter(x=ymd, y=bolingerband_upper, name="upper", line_color='#8B4513')
|
||||
bolinger_lower = go.Scatter(x=ymd, y=bolingerband_lower, name="lower", line_color='#8B4513')
|
||||
changeLine = go.Scatter(x=ymd, y=ichimokucloud_changeLine, name="changeLine", line_color='#000000')
|
||||
baseLine = go.Scatter(x=ymd, y=ichimokucloud_baseLine, name="baseLine", line_color='#FF0000')
|
||||
|
||||
candle_data = [candle_stick, avg5, avg20, avg60, avg120, avg240, bolinger_upper, bolinger_lower, changeLine, baseLine]
|
||||
#candle_data = [candle_stick, bolinger_upper, bolinger_lower, changeLine, baseLine]
|
||||
|
||||
volume = go.Bar(x=ymd, y=volume, name="volume")
|
||||
volume_data = [volume]
|
||||
|
||||
# stochastic
|
||||
slow_k = go.Scatter(x=ymd, y=stochastic_slow_k, name="Slow%K", line_color='#8B4513')
|
||||
slow_d = go.Scatter(x=ymd, y=stochastic_slow_d, name="Slow%D", line_color='#4169E1')
|
||||
stochastic_data = [slow_k, slow_d]
|
||||
|
||||
fig = subplots.make_subplots(rows=3, cols=1, subplot_titles=('차트', '거래량', 'Stochastic'), row_heights=[1200, 500, 500])
|
||||
|
||||
for trace in candle_data:
|
||||
fig.append_trace(trace, 1, 1)
|
||||
for trace in volume_data:
|
||||
fig.append_trace(trace, 2, 1)
|
||||
for trace in stochastic_data:
|
||||
fig.append_trace(trace, 3, 1)
|
||||
|
||||
fig.update_layout(height=2200, xaxis_rangeslider_visible=False)
|
||||
|
||||
return fig
|
||||
|
||||
def analyzeFinalScore(self, STOCK):
|
||||
status = ""
|
||||
if STOCK['volume'][0] > 100000 and STOCK['close'][0] > 1000:
|
||||
# 거래량이 100만 이상이고, 종가가 1천원 이상인지 체크 (https://happpy-rich.tistory.com/94)
|
||||
|
||||
# 정배열 체크
|
||||
temp_status = self.common.check_RightArrange(STOCK)
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
# 20일선 돌파
|
||||
temp_status = self.common.check_Dolpa_Jiji(STOCK, '20')
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
# 60일선 돌파
|
||||
temp_status = self.common.check_Dolpa_Jiji(STOCK, '60')
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
# 120일선 돌파
|
||||
temp_status = self.common.check_Dolpa_Jiji(STOCK, '120')
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
# 240일선 돌파
|
||||
temp_status = self.common.check_Dolpa_Jiji(STOCK, '240')
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
# 20일선 지지 매수가 추천
|
||||
temp_status = self.common.check_Dolpa_Jiji_20(STOCK)
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
# 음봉인데 어제보다 종가가 더 높은 경우
|
||||
# 이 경우 정배열 상태인지도 함께 체크를 한다.
|
||||
higher_umbong_status = self.common.checkHigherUmbong(STOCK)
|
||||
if higher_umbong_status != "":
|
||||
status += higher_umbong_status
|
||||
|
||||
"""
|
||||
# 단타 #1
|
||||
temp_status = self.common.check_Danta1(STOCK)
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
# 단타 #2
|
||||
temp_status = self.common.check_Danta2(STOCK)
|
||||
if temp_status != "":
|
||||
status += temp_status
|
||||
|
||||
all_upper_cross_status = self.common.checkAllUpperCross(STOCK)
|
||||
if all_upper_cross_status != "":
|
||||
status += all_upper_cross_status
|
||||
|
||||
# 1주일 동안 몇 10% 이상 오른 종목
|
||||
W1Rise = self.common.check_W1Rise(STOCK, 0.1)
|
||||
if W1Rise != "":
|
||||
status += W1Rise
|
||||
|
||||
# 1일 동안 몇 10% 이상 내린 종목
|
||||
W1Fall = self.common.check_D1Fall(STOCK, -0.1)
|
||||
if W1Fall != "":
|
||||
status += W1Fall
|
||||
"""
|
||||
|
||||
# GOLDENCROSS#1은 바로 매수하지 않고, 이 시점 이후로 5일선이 20일선을 하방으로 뚫었다가 다시 20일선을 상방으로 뚫는 순간 매수를 시도한다.
|
||||
# GOLDENCROSS#2은 바로 매수 가능
|
||||
# GOLDENCROSS#3은 바로 매수 가능
|
||||
golden_cross_status = self.common.check_golded_cross(STOCK)
|
||||
if golden_cross_status != "":
|
||||
status += golden_cross_status
|
||||
|
||||
"""
|
||||
# BUYINGBEARMARKET#1은 바로 매수 가능
|
||||
# BUYINGBEARMARKET#2은 바로 매수 가능
|
||||
bearmarket_buying_status = self.common.check_bearmarket_buying(STOCK)
|
||||
if bearmarket_buying_status != "":
|
||||
status += bearmarket_buying_status
|
||||
"""
|
||||
|
||||
# STOCHASTIC
|
||||
stochastic_status = self.common.check_stochastic(STOCK)
|
||||
if stochastic_status != "":
|
||||
status += stochastic_status
|
||||
|
||||
# YANGBONG
|
||||
"""
|
||||
longYangBongAfterUmBong_status = self.common.checkLongYangBongAfterUmBong(STOCK)
|
||||
# 어제 음봉 이후 장대양봉이었다면,
|
||||
if longYangBongAfterUmBong_status != "":
|
||||
status += longYangBongAfterUmBong_status
|
||||
"""
|
||||
|
||||
# Doji
|
||||
doji_status = self.common.checkDoji(STOCK)
|
||||
# 하락 추세에서 도지가 나오면 매수
|
||||
if doji_status != "":
|
||||
status += doji_status
|
||||
|
||||
"""---------------------------------
|
||||
# Gravestone
|
||||
gravestone_status = self.common.checkGravestone(STOCK)
|
||||
# 상승 추세에서 그레이브스톤이 나오면 매도
|
||||
if gravestone_status != "":
|
||||
status += gravestone_status
|
||||
---------------------------------"""
|
||||
|
||||
"""
|
||||
# Dragonfly
|
||||
dragonfly_status = self.common.checkDragonfly(STOCK)
|
||||
# 하락 추세에서 드레곤플라이가 나오면 매수
|
||||
if dragonfly_status != "":
|
||||
status += dragonfly_status
|
||||
|
||||
# Hammer
|
||||
hammer_status = self.common.checkHammer(STOCK)
|
||||
# 하락 추세에서 해머가 나오면 매수
|
||||
if hammer_status != "":
|
||||
status += hammer_status
|
||||
"""
|
||||
|
||||
"""---------------------------------
|
||||
# Hangingman
|
||||
hangingman_status = self.common.checkHangingman(STOCK)
|
||||
# 상승 추세에서 행잉맨이 나오면 매도
|
||||
if hangingman_status != "":
|
||||
status += hangingman_status
|
||||
---------------------------------"""
|
||||
|
||||
"""
|
||||
# 상승장악형 (Engulfing) - 다음 날도 양봉이라면 매수
|
||||
engulfing_status = self.common.checkEngulfingHigh(STOCK)
|
||||
# 하락 추세에서 상승장악형이 나오면 매수
|
||||
if engulfing_status != "":
|
||||
status += engulfing_status
|
||||
"""
|
||||
|
||||
"""---------------------------------
|
||||
# 하락장악형 (Engulfing)
|
||||
engulfing_status = self.common.checkEngulfingLow(STOCK)
|
||||
# 상승 추세에서 하락장악형이 나오면 매도
|
||||
if engulfing_status != "":
|
||||
status += engulfing_status
|
||||
---------------------------------"""
|
||||
|
||||
"""
|
||||
# 상승 포아형 (Harami)
|
||||
harami_status = self.common.checkHaramiHigh(STOCK)
|
||||
# 하락 추세에서 상승포아형이 나오면 매수
|
||||
if harami_status != "":
|
||||
status += harami_status
|
||||
"""
|
||||
|
||||
"""---------------------------------
|
||||
# 하락 포아형 (Harami)
|
||||
harami_status = self.common.checkHaramiLow(STOCK)
|
||||
# 상승 추세에서 하락포아형이 나오면 매도
|
||||
if harami_status != "":
|
||||
status += harami_status
|
||||
---------------------------------"""
|
||||
|
||||
"""
|
||||
# 관통형 (piercing)
|
||||
piercing_status = self.common.checkPiercing(STOCK)
|
||||
# 하락 추세에서 관통형이 나오면 매수
|
||||
if piercing_status != "":
|
||||
status += piercing_status
|
||||
"""
|
||||
|
||||
"""---------------------------------
|
||||
# 흑운형 (Dark-cloud)
|
||||
darkcloud_status = self.common.checkDarkCloud(STOCK)
|
||||
# 상승 추세에서 흑운형이 나오면 매도
|
||||
if darkcloud_status != "":
|
||||
status += darkcloud_status
|
||||
---------------------------------"""
|
||||
|
||||
"""
|
||||
# 샛별 (Morning start)
|
||||
morningstar_status = self.common.checkMorningstar(STOCK)
|
||||
# 하락 추세에서 샛별형이 나오면 매수
|
||||
if morningstar_status != "":
|
||||
status += morningstar_status
|
||||
"""
|
||||
|
||||
"""---------------------------------
|
||||
# 저녁별 (Evening start)
|
||||
eveningstar_status = self.common.checkEveningstar(STOCK)
|
||||
# 상승 추세에서 저녁별형이 나오면 매도
|
||||
if eveningstar_status != "":
|
||||
status += eveningstar_status
|
||||
---------------------------------"""
|
||||
|
||||
|
||||
return status
|
||||
|
||||
def getPositionalEnergy(self, close):
|
||||
# 260 (= 52 * 5)일 중 가장 찾은 금액과 가장 높았던 금액 중 현재가의 위치 계산
|
||||
|
||||
top = close[0]
|
||||
bottom = close[0]
|
||||
|
||||
for i in range(1, 260):
|
||||
if i >= len(close):
|
||||
break
|
||||
if top < close[i]:
|
||||
top = close[i]
|
||||
if bottom > close[i]:
|
||||
bottom = close[i]
|
||||
|
||||
if top-close[0] == 0:
|
||||
energy1 = 100.0
|
||||
else:
|
||||
energy1 = round((close[0]-bottom) / (top-close[0]), 2)
|
||||
|
||||
energy2 = round((close[0] / top), 2)
|
||||
|
||||
return energy1, energy2
|
||||
|
||||
def makeDir(self, type):
|
||||
if os.path.isdir(self.outPath + "/" + type):
|
||||
os.rmdir(self.outPath + "/" + type)
|
||||
os.mkdir(self.outPath + "/" + type)
|
||||
return
|
||||
|
||||
def makeDirectory(self, outPath):
|
||||
self.outPath = outPath
|
||||
if os.path.isdir(outPath):
|
||||
shutil.rmtree(outPath)
|
||||
os.mkdir(outPath)
|
||||
|
||||
self.makeDir("참고_0_스토케스틱이 10 미만")
|
||||
self.makeDir("참고_0_bolingerband 하단")
|
||||
self.makeDir("참고_0_260일 위치 에너지가 10% 미만")
|
||||
self.makeDir("참고_0_260일 가격 반토막 이상")
|
||||
self.makeDir("참고_0_240일선 아래")
|
||||
self.makeDir("참고_1_기준선 위 전환선 올라옴")
|
||||
self.makeDir("참고_1_기준선 위 120일선 올라옴")
|
||||
self.makeDir("참고_1_기준선 위 200일선 올라옴")
|
||||
self.makeDir("참고_1_기준선 위 240일선 올라옴")
|
||||
self.makeDir("참고_1_240일선 돌파")
|
||||
self.makeDir("참고_1_200일선 돌파")
|
||||
self.makeDir("참고_1_20일선 돌파")
|
||||
self.makeDir("참고_1_60일선 돌파")
|
||||
self.makeDir("참고_1_GoldenCross")
|
||||
self.makeDir("참고_1_모든 라인 돌파")
|
||||
|
||||
self.makeDir("1_캔들_전환선_위로_올라옴")
|
||||
self.makeDir("2_캔들_기준선_위로_올라옴")
|
||||
self.makeDir("3_후행스팬_캔들_위로_올라옴")
|
||||
self.makeDir("-1_캔들_전환선_아래로_내려옴")
|
||||
self.makeDir("-2_캔들_기준선_아래로_내려옴")
|
||||
self.makeDir("-3_후행스팬_캔들_아래로_내려옴")
|
||||
|
||||
self.makeDir("1_거래량_상승")
|
||||
self.makeDir("1_코로나_근접")
|
||||
self.makeDir("4_정배열")
|
||||
self.makeDir("5_거래량 7배 이상_120일선_위_기준선")
|
||||
self.makeDir("6_거래량 7배 이상_20일선_위_기준선")
|
||||
self.makeDir("7_bolingerband 하단 돌파 상승")
|
||||
self.makeDir("8_저점_매수관심")
|
||||
self.makeDir("9_저점_매수")
|
||||
|
||||
|
||||
return
|
||||
|
||||
def writeFile(self, type, CODE, NAME, top, stock, state):
|
||||
# 3년 이내 한번이라도 영업이익이 났는지 체크를 함
|
||||
fnguide = None
|
||||
if CODE in self.fnguide:
|
||||
fnguide = self.fnguide[CODE]
|
||||
check = True
|
||||
if fnguide:
|
||||
check = False
|
||||
for item in fnguide:
|
||||
if item['business_profits'] > 0:
|
||||
check = True
|
||||
|
||||
if check:
|
||||
fig = self.draw(stock)
|
||||
title = "%s (%s), %d %s 차트 (<a href=\"https://alphasquare.co.kr/home/stock/financial-information?code=%s\">URL1</a>, <a href=\"https://www.tradingview.com/chart/jJ8zOXz0/?symbol=KRX:%s\">URL2</a>)" % (NAME, CODE, stock['close'][0], state, CODE, CODE)
|
||||
fig['layout'].update(title=title)
|
||||
|
||||
fileName = self.outPath + "/" + str(type)
|
||||
fileName = "%s/%s_%s_%s_%s.html" % (fileName, top, NAME.replace(" ", ""), CODE, state)
|
||||
po.write_html(fig, file=fileName, auto_open=False)
|
||||
return
|
||||
|
||||
def checkVolume(self, p_volume, volume):
|
||||
if 0 < p_volume <= 10000 and p_volume * 700 < volume:
|
||||
return True
|
||||
if 10000 < p_volume <= 50000 and p_volume * 40 < volume:
|
||||
return True
|
||||
if 50000 < p_volume <= 100000 and p_volume * 25 < volume:
|
||||
return True
|
||||
if 100000 < p_volume <= 200000 and p_volume * 15 < volume:
|
||||
return True
|
||||
if 200000 < p_volume <= 700000 and p_volume * 13 < volume:
|
||||
return True
|
||||
if 700000 < p_volume <= 1000000 and p_volume * 10 < volume:
|
||||
return True
|
||||
if 5000000 < p_volume <= 5000000 and p_volume * 5 < volume:
|
||||
return True
|
||||
if 5000000 < p_volume and p_volume * 4 < volume:
|
||||
return True
|
||||
return False
|
||||
|
||||
# 후보 찾기
|
||||
def findCandidate(self, outPath):
|
||||
self.makeDirectory(outPath)
|
||||
|
||||
stockTableName = 'stock'
|
||||
stockAnalysisTableName = 'stock_analysis'
|
||||
|
||||
conn = sqlite3.connect(self.stockFileName)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('SELECT distinct code, name FROM ' + stockTableName + ' order by code')
|
||||
items = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
for idx, item in enumerate(items):
|
||||
CODE = item[0]
|
||||
NAME = item[1]
|
||||
print (idx, CODE, NAME)
|
||||
|
||||
top = "0"
|
||||
if CODE in self.top500:
|
||||
top = str(self.top500[CODE][0])
|
||||
|
||||
conn = sqlite3.connect(self.stockFileName)
|
||||
cursor = conn.cursor()
|
||||
|
||||
sql = 'SELECT ymd, close, open, high, low, volume '
|
||||
sql += ' FROM ' + stockTableName + ' where CODE=? order by ymd desc limit 512'
|
||||
cursor.execute(sql, (CODE,))
|
||||
prices = cursor.fetchall()
|
||||
|
||||
ymd_, close, open, high, low, volume = [], [], [], [], [], []
|
||||
for price in prices:
|
||||
ymd_.append(price[0])
|
||||
close.append(price[1])
|
||||
open.append(price[2])
|
||||
high.append(price[3])
|
||||
low.append(price[4])
|
||||
volume.append(price[5])
|
||||
|
||||
covid_low_ymd_index = 0
|
||||
for i in ymd_:
|
||||
if i == '2020.03.19':
|
||||
break
|
||||
covid_low_ymd_index += 1
|
||||
|
||||
sql = 'SELECT ymd, avg5, avg10, avg20, avg60, avg120, avg200, avg240, '
|
||||
sql += ' bolingerband_upper, bolingerband_lower, bolingerband_middle, '
|
||||
sql += ' ichimokucloud_changeLine, ichimokucloud_baseLine, ichimokucloud_leadingSpan1, ichimokucloud_leadingSpan2, '
|
||||
sql += ' stochastic_fast_k, stochastic_slow_k, stochastic_slow_d '
|
||||
sql += ' FROM ' + stockAnalysisTableName + ' where CODE=? order by ymd desc limit 512'
|
||||
cursor.execute(sql, (CODE,))
|
||||
prices = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
ymd = []
|
||||
avg5, avg10, avg20, avg60, avg120, avg200, avg240 = [], [], [], [], [], [], []
|
||||
bolingerband_upper, bolingerband_lower, bolingerband_middle = [], [], []
|
||||
ichimokucloud_changeLine, ichimokucloud_baseLine, ichimokucloud_leadingSpan1, ichimokucloud_leadingSpan2 = [], [], [], []
|
||||
stochastic_fast_k, stochastic_slow_k, stochastic_slow_d = [], [], []
|
||||
|
||||
for price in prices:
|
||||
ymd.append(price[0])
|
||||
avg5.append(price[1])
|
||||
avg10.append(price[2])
|
||||
avg20.append(price[3])
|
||||
avg60.append(price[4])
|
||||
avg120.append(price[5])
|
||||
avg200.append(price[6])
|
||||
avg240.append(price[7])
|
||||
bolingerband_upper.append(price[8])
|
||||
bolingerband_lower.append(price[9])
|
||||
bolingerband_middle.append(price[10])
|
||||
ichimokucloud_changeLine.append(price[11])
|
||||
ichimokucloud_baseLine.append(price[12])
|
||||
ichimokucloud_leadingSpan1.append(price[13])
|
||||
ichimokucloud_leadingSpan2.append(price[14])
|
||||
stochastic_fast_k.append(price[15])
|
||||
stochastic_slow_k.append(price[16])
|
||||
stochastic_slow_d.append(price[17])
|
||||
|
||||
stock = {
|
||||
"ymd":ymd,
|
||||
"close":close, "open":open, "high":high, "low":low, "volume":volume,
|
||||
"avg5":avg5, "avg10":avg10, "avg20":avg20, "avg60":avg60, "avg120":avg120, "avg200":avg200, "avg240":avg240,
|
||||
"bolingerband_upper":bolingerband_upper, "bolingerband_lower":bolingerband_lower, "bolingerband_middle":bolingerband_middle,
|
||||
"ichimokucloud_changeLine":ichimokucloud_changeLine, "ichimokucloud_baseLine":ichimokucloud_baseLine, "ichimokucloud_leadingSpan1":ichimokucloud_leadingSpan1, "ichimokucloud_leadingSpan2":ichimokucloud_leadingSpan2,
|
||||
"stochastic_fast_k":stochastic_fast_k, "stochastic_slow_k":stochastic_slow_k, "stochastic_slow_d":stochastic_slow_d
|
||||
}
|
||||
|
||||
stochastic_score = stochastic_slow_k[0]
|
||||
|
||||
# 위치 에너지
|
||||
positionalEnergy1, positionalEnergy2 = self.getPositionalEnergy(close)
|
||||
|
||||
if volume[0] > 100000 and close[0] > 1000:
|
||||
# 종목 상태 체크 분석
|
||||
state = self.analyzeFinalScore(stock)
|
||||
|
||||
# 0_스토케스틱이 10 미만
|
||||
if len(close) > 5 and stochastic_score is not None and stochastic_score < 10:
|
||||
type = "참고_0_스토케스틱이 10 미만"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 0_bolingerband 하단
|
||||
if len(close) > 60:
|
||||
if bolingerband_lower[0] is not None and close[0] < bolingerband_lower[0]:
|
||||
type = "참고_0_bolingerband 하단"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 0_260일 위치 에너지가 10% 미만
|
||||
if len(close) > 5 and positionalEnergy1 is not None and positionalEnergy1 < 0.1:
|
||||
type = "참고_0_260일 위치 에너지가 10% 미만"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 0_260일 가격 반토막 이상
|
||||
if len(close) > 5 and positionalEnergy2 is not None and positionalEnergy2 < 0.5:
|
||||
type = "참고_0_260일 가격 반토막 이상"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 0_종가가 240일선 아래라면 매수한다.
|
||||
if close[0] < avg240[0]:
|
||||
type = "참고_0_240일선 아래"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 1_기준선 위 전환선 올라옴
|
||||
if len(close) > 50:
|
||||
if ((ichimokucloud_changeLine[0] > ichimokucloud_baseLine[0] and
|
||||
ichimokucloud_changeLine[1] <= ichimokucloud_baseLine[1] and
|
||||
ichimokucloud_changeLine[2] <= ichimokucloud_baseLine[2] and
|
||||
ichimokucloud_changeLine[3] <= ichimokucloud_baseLine[3] and
|
||||
ichimokucloud_changeLine[4] <= ichimokucloud_baseLine[4]) and
|
||||
volume[0] > volume[1]):
|
||||
type = "참고_1_기준선 위 전환선 올라옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# "1_기준선 위 120일선 올라옴"
|
||||
if len(close) > 50:
|
||||
if ((avg120[0] > ichimokucloud_baseLine[0] and
|
||||
avg120[1] <= ichimokucloud_baseLine[1] and
|
||||
avg120[2] <= ichimokucloud_baseLine[2] and
|
||||
avg120[3] <= ichimokucloud_baseLine[3] and
|
||||
avg120[4] <= ichimokucloud_baseLine[4]) and
|
||||
volume[0] > volume[1]):
|
||||
type = "참고_1_기준선 위 120일선 올라옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# "1_기준선 위 200일선 올라옴"
|
||||
if len(close) > 50:
|
||||
if ((avg200[0] > ichimokucloud_baseLine[0] and
|
||||
avg200[1] <= ichimokucloud_baseLine[1] and
|
||||
avg200[2] <= ichimokucloud_baseLine[2] and
|
||||
avg200[3] <= ichimokucloud_baseLine[3] and
|
||||
avg200[4] <= ichimokucloud_baseLine[4]) and
|
||||
volume[0] > volume[1]):
|
||||
type = "참고_1_기준선 위 200일선 올라옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# "1_기준선 위 240일선 올라옴"
|
||||
if len(close) > 50:
|
||||
if ((avg240[0] > ichimokucloud_baseLine[0] and
|
||||
avg240[1] <= ichimokucloud_baseLine[1] and
|
||||
avg240[2] <= ichimokucloud_baseLine[2] and
|
||||
avg240[3] <= ichimokucloud_baseLine[3] and
|
||||
avg240[4] <= ichimokucloud_baseLine[4]) and
|
||||
volume[0] > volume[1]):
|
||||
type = "참고_1_기준선 위 240일선 올라옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 1_종가가 200일선 돌파
|
||||
if len(close) > 5 and close[0] >= avg200[0] and close[1] < avg200[1] and close[2] < avg200[2] and close[3] < avg200[3] and close[4] < avg200[4]:
|
||||
type = "참고_1_200일선 돌파"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 1_종가가 240일선 돌파
|
||||
if len(close) > 5 and close[0] >= avg240[0] and close[1] < avg240[1] and close[2] < avg240[2] and close[3] < avg240[3] and close[4] < avg240[4]:
|
||||
type = "참고_1_240일선 돌파"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 1_20일선 돌파
|
||||
temp_status = self.common.check_Dolpa_Jiji(stock, '20')
|
||||
if temp_status != "":
|
||||
type = "참고_1_20일선 돌파"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 1_60일선 돌파
|
||||
temp_status = self.common.check_Dolpa_Jiji(stock, '60')
|
||||
if temp_status != "":
|
||||
type = "참고_1_60일선 돌파"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 1_골든크로스
|
||||
golden_cross_status = self.common.check_golded_cross(stock)
|
||||
if golden_cross_status != "":
|
||||
type = "참고_1_GoldenCross"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
# 1_모든 라인 돌파
|
||||
if (len(close) > 50 and
|
||||
close[0] > max(open[0], avg5[0], avg20[0], avg60[0], avg120[0], avg240[0], bolingerband_upper[0], ichimokucloud_changeLine[0], ichimokucloud_baseLine[0]) and
|
||||
open[0] < max(open[0], avg5[0], avg20[0], avg60[0], avg120[0], avg240[0], bolingerband_upper[0], ichimokucloud_changeLine[0], ichimokucloud_baseLine[0])):
|
||||
type = "참고_1_모든 라인 돌파"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
### 코스피 지수 기준 숏 전략:
|
||||
# 캔들이 전환선 붕괴시키면 1을 매수한다.
|
||||
# 기준선 붕괴하면 3을 매수한다.
|
||||
# 후행스팬 캔들 밑으로 내려오면 1씩 매수한다.
|
||||
### 매도전략:
|
||||
# 캔들이 전환선 올라오면 1을 매도한다.
|
||||
# 기준선 올라타면 3을 매도한다.
|
||||
# 후행스팬 캔들 위로 올라오면 매도한다.
|
||||
|
||||
if (len(close) > 50 and close[1] < ichimokucloud_changeLine[1] and ichimokucloud_changeLine[0] < close[0]):
|
||||
type = "1_캔들_전환선_위로_올라옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
if (len(close) > 50 and close[1] < ichimokucloud_baseLine[1] and ichimokucloud_baseLine[0] < close[0]):
|
||||
type = "2_캔들_기준선_위로_올라옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
if (len(close) > 50 and close[0] < close[26] and close[25] < close[0]):
|
||||
type = "3_후행스팬_캔들_위로_올라옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if (len(close) > 50 and close[1] > ichimokucloud_changeLine[1] and ichimokucloud_changeLine[0] > close[0]):
|
||||
type = "-1_캔들_전환선_아래로_내려옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
if (len(close) > 50 and close[1] > ichimokucloud_baseLine[1] and ichimokucloud_baseLine[0] > close[0]):
|
||||
type = "-2_캔들_기준선_아래로_내려옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
if (len(close) > 50 and close[0] > close[26] and close[25] > close[0]):
|
||||
type = "-3_후행스팬_캔들_아래로_내려옴"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if (len(close) > 2 and close[1] < close[0] and open[0] < close[0] and
|
||||
self.checkVolume(volume[1], volume[0])):
|
||||
type = "1_거래량_상승"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if (len(close) > covid_low_ymd_index and
|
||||
close[0] > close[covid_low_ymd_index]*0.8 and close[0] < close[covid_low_ymd_index]*1.1):
|
||||
type = "1_코로나_근접"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
right_arrange = self.common.check_RightArrange(stock)
|
||||
if right_arrange != "":
|
||||
type = "4_정배열"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if len(volume)>2:
|
||||
if volume[0] > volume[1]*7:
|
||||
if ichimokucloud_baseLine[0] is not None and avg120[0] < ichimokucloud_baseLine[0]:
|
||||
type = "5_거래량 7배 이상_120일선_위_기준선"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if len(volume)>2:
|
||||
if volume[0] > volume[1]*7:
|
||||
if ichimokucloud_baseLine[0] is not None and avg20[0] < ichimokucloud_baseLine[0]:
|
||||
type = "6_거래량 7배 이상_20일선_위_기준선"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if len(close) > 60:
|
||||
if ((bolingerband_lower[0] is not None and bolingerband_lower[1] is not None) and
|
||||
close[0] > bolingerband_lower[0] and close[1] < bolingerband_lower[1]):
|
||||
type = "7_bolingerband 하단 돌파 상승"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if len(close) > 50:
|
||||
if avg5[0]<avg20[0]<avg60[0]:
|
||||
if open[0] <= bolingerband_lower[0] and close[0] < bolingerband_lower[0]:
|
||||
type = "8_저점_매수관심"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
if len(close) > 50:
|
||||
if avg5[0]<avg20[0]<avg60[0]<avg120[0]<avg240[0]:
|
||||
if open[0] <= bolingerband_lower[0] and close[0] < bolingerband_lower[0]:
|
||||
type = "9_저점_매수"
|
||||
self.writeFile(type, CODE, NAME, top, stock, state)
|
||||
|
||||
return
|
||||
|
||||
def get_moving_average(self, stock):
|
||||
q_5 = MovingAverage(5)
|
||||
q_10 = MovingAverage(10)
|
||||
q_20 = MovingAverage(20)
|
||||
q_60 = MovingAverage(60)
|
||||
q_120 = MovingAverage(120)
|
||||
q_200 = MovingAverage(200)
|
||||
q_240 = MovingAverage(240)
|
||||
|
||||
for i in range(len(stock)):
|
||||
q_5.enqueue(stock[i]['close'])
|
||||
q_10.enqueue(stock[i]['close'])
|
||||
q_20.enqueue(stock[i]['close'])
|
||||
q_60.enqueue(stock[i]['close'])
|
||||
q_120.enqueue(stock[i]['close'])
|
||||
q_200.enqueue(stock[i]['close'])
|
||||
q_240.enqueue(stock[i]['close'])
|
||||
|
||||
stock[i]['avg5'] = q_5.avg()
|
||||
stock[i]['avg10'] = q_10.avg()
|
||||
stock[i]['avg20'] = q_20.avg()
|
||||
stock[i]['avg60'] = q_60.avg()
|
||||
stock[i]['avg120'] = q_120.avg()
|
||||
stock[i]['avg200'] = q_200.avg()
|
||||
stock[i]['avg240'] = q_240.avg()
|
||||
|
||||
return
|
||||
|
||||
def analyze(self):
|
||||
stockTableName = 'stock'
|
||||
stockAnalysisTableName = 'stock_analysis'
|
||||
|
||||
conn = sqlite3.connect(self.stockFileName)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 테이블 생성
|
||||
cursor.execute("CREATE TABLE IF NOT EXISTS " + stockAnalysisTableName + " (CODE text, NAME text, ymd text, avg5 REAL, avg10 REAL, avg20 REAL, avg60 REAL, avg120 REAL, avg200 REAL, avg240 REAL, bolingerband_upper REAL, bolingerband_lower REAL, bolingerband_middle REAL, ichimokucloud_changeLine REAL, ichimokucloud_baseLine REAL, ichimokucloud_leadingSpan1 REAL, ichimokucloud_leadingSpan2 REAL, stochastic_fast_k REAL, stochastic_slow_k REAL, stochastic_slow_d REAL)")
|
||||
|
||||
# 키 생성
|
||||
create_key = "CREATE INDEX IF NOT EXISTS " + stockAnalysisTableName + "_idx on " + stockAnalysisTableName + " (CODE, ymd) "
|
||||
cursor.execute(create_key)
|
||||
|
||||
cursor.execute('SELECT distinct code, name FROM ' + stockTableName + ' order by code')
|
||||
items = cursor.fetchall()
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
for rowid, item in enumerate(items):
|
||||
conn = sqlite3.connect(self.stockFileName)
|
||||
cursor = conn.cursor()
|
||||
|
||||
stock = {"CODE": item[0], "NAME": item[1], "PRICE":[]}
|
||||
print("# :", rowid, ", CODE: ", stock['CODE'], ", NAME: ", stock['NAME'])
|
||||
|
||||
cursor.execute('SELECT ymd FROM ' + stockAnalysisTableName + ' WHERE CODE=? order by ymd desc', (stock['CODE'],))
|
||||
result = cursor.fetchone()
|
||||
|
||||
sql = 'SELECT ymd, close, diff, open, high, low, volume FROM ' + stockTableName + ' where CODE=? order by ymd desc '
|
||||
if result is not None:
|
||||
sql += ' limit 300'
|
||||
cursor.execute(sql, (stock['CODE'],))
|
||||
items = cursor.fetchall()
|
||||
|
||||
items_reverse = reversed(items)
|
||||
for item in items_reverse:
|
||||
stock['PRICE'].append(
|
||||
{"ymd": item[0],
|
||||
"close": item[1],
|
||||
"diff": item[2],
|
||||
"open": item[3],
|
||||
"high": item[4],
|
||||
"low": item[5],
|
||||
"volume": item[6],
|
||||
"avg5": -1,
|
||||
"avg10": -1,
|
||||
"avg20": -1,
|
||||
"avg60": -1,
|
||||
"avg120": -1,
|
||||
"avg200": -1,
|
||||
"avg240": -1,
|
||||
"bolingerband_upper": -1,
|
||||
"bolingerband_lower": -1,
|
||||
"bolingerband_middle": -1,
|
||||
"ichimokucloud_changeLine": -1,
|
||||
"ichimokucloud_baseLine": -1,
|
||||
"ichimokucloud_leadingSpan1": -1,
|
||||
"ichimokucloud_leadingSpan2": -1,
|
||||
"stochastic_fast_k": -1,
|
||||
"stochastic_slow_k": -1,
|
||||
"stochastic_slow_d": -1})
|
||||
|
||||
# 이동 평균 계산
|
||||
stock["PRICE"] = sorted(stock["PRICE"], key=lambda x: x['ymd'])
|
||||
self.get_moving_average(stock["PRICE"])
|
||||
|
||||
self.ichimokuCloud.analyze(stock)
|
||||
self.stochastic.analyze(stock)
|
||||
self.bolingerBand.analyze(stock)
|
||||
|
||||
sorted_stock = sorted(stock["PRICE"], key=lambda x: x['ymd'], reverse=True)
|
||||
for price in sorted_stock:
|
||||
cursor.execute('SELECT * FROM ' + stockAnalysisTableName + ' WHERE CODE=? and ymd=?', (stock['CODE'], price['ymd'],))
|
||||
result = cursor.fetchone()
|
||||
if result == None:
|
||||
sql = "INSERT INTO " + stockAnalysisTableName + "(CODE, NAME, ymd, avg5, avg10, avg20, avg60, avg120, avg200, avg240, "
|
||||
sql += " bolingerband_upper, bolingerband_lower, bolingerband_middle, "
|
||||
sql += " ichimokucloud_changeLine, ichimokucloud_baseLine, ichimokucloud_leadingSpan1, ichimokucloud_leadingSpan2, "
|
||||
sql += " stochastic_fast_k, stochastic_slow_k, stochastic_slow_d) "
|
||||
sql += " VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
cursor.execute(sql, (stock["CODE"], stock["NAME"], price['ymd'], price['avg5'], price['avg10'], price['avg20'], price['avg60'], price['avg120'], price['avg200'], price['avg240'],
|
||||
price['bolingerband_upper'], price['bolingerband_lower'], price['bolingerband_middle'],
|
||||
price['ichimokucloud_changeLine'], price['ichimokucloud_baseLine'], price['ichimokucloud_leadingSpan1'], price['ichimokucloud_leadingSpan2'],
|
||||
price['stochastic_fast_k'], price['stochastic_slow_k'], price['stochastic_slow_d'],))
|
||||
else:
|
||||
break
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
start = time.time()
|
||||
@@ -880,7 +1767,31 @@ if __name__ == "__main__":
|
||||
stockFileName = PROJECT_HOME + '/resources/stock.db'
|
||||
analyzer = AnalyzerSqlite(PROJECT_HOME, stockFileName)
|
||||
|
||||
#analyzer.analyze()
|
||||
analyzer.analyze()
|
||||
|
||||
day = datetime.today().strftime("%Y%m%d")
|
||||
|
||||
# HTML 출력
|
||||
outPath = PROJECT_HOME + "/resources/analysis/"+day
|
||||
if os.path.isdir(outPath):
|
||||
shutil.rmtree(outPath)
|
||||
os.mkdir(outPath)
|
||||
print("print to Html...")
|
||||
analyzer.findCandidate(outPath)
|
||||
|
||||
|
||||
print("time : %6.2f 초" % (time.time() - start))
|
||||
|
||||
print("done...")
|
||||
|
||||
|
||||
start = time.time()
|
||||
PROJECT_HOME = os.path.join(os.path.dirname(os.path.join(os.path.dirname(os.path.join(os.path.dirname(__file__))))))
|
||||
|
||||
stockFileName = PROJECT_HOME + '/resources/stock.db'
|
||||
analyzer = AnalyzerSqlite(PROJECT_HOME, stockFileName)
|
||||
|
||||
analyzer.analyze()
|
||||
|
||||
day = datetime.today().strftime("%Y%m%d")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user